From 2cbc4eb54fe155f2f3d893872af0e0c079f0da2e Mon Sep 17 00:00:00 2001 From: Alex Leeds Date: Tue, 14 Mar 2023 18:26:46 -0400 Subject: [PATCH] eufy: support multiple p2p streams (#624) --- plugins/eufy/package-lock.json | 3 +- plugins/eufy/src/main.ts | 59 ++++++-------- plugins/eufy/src/stream.ts | 142 ++++++++++++++++++++++++++++----- 3 files changed, 145 insertions(+), 59 deletions(-) diff --git a/plugins/eufy/package-lock.json b/plugins/eufy/package-lock.json index 8e5d60c08..9fbc34719 100644 --- a/plugins/eufy/package-lock.json +++ b/plugins/eufy/package-lock.json @@ -33,6 +33,7 @@ } }, "../../packages/h264-repacketizer": { + "name": "@scrypted/h264-repacketizer", "version": "0.0.6", "license": "ISC", "devDependencies": { @@ -43,7 +44,7 @@ }, "../../sdk": { "name": "@scrypted/sdk", - "version": "0.2.84", + "version": "0.2.85", "license": "ISC", "dependencies": { "@babel/preset-typescript": "^7.18.6", diff --git a/plugins/eufy/src/main.ts b/plugins/eufy/src/main.ts index 8bc3febc7..03564e631 100644 --- a/plugins/eufy/src/main.ts +++ b/plugins/eufy/src/main.ts @@ -2,7 +2,7 @@ import { listenSingleRtspClient } from '@scrypted/common/src/rtsp-server'; import { addTrackControls, parseSdp } from '@scrypted/common/src/sdp-utils'; import sdk, { Battery, Camera, Device, DeviceProvider, FFmpegInput, MediaObject, MotionSensor, RequestMediaStreamOptions, RequestPictureOptions, ResponseMediaStreamOptions, ResponsePictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue, VideoCamera } from '@scrypted/sdk'; import { StorageSettings } from '@scrypted/sdk/storage-settings'; -import eufy, { CaptchaOptions, EufySecurity } from 'eufy-security-client'; +import eufy, { CaptchaOptions, EufySecurity, P2PClientProtocol, P2PConnectionType } from 'eufy-security-client'; import { startRtpForwarderProcess } from '../../webrtc/src/rtp-forwarders'; import { Deferred } from '@scrypted/common/src/deferred'; @@ -11,17 +11,14 @@ import { LocalLivestreamManager } from './stream'; const { deviceManager, mediaManager, systemManager } = sdk; -class EufyCamera extends ScryptedDeviceBase implements Camera, VideoCamera, Battery, MotionSensor { +class EufyCamera extends ScryptedDeviceBase implements VideoCamera, MotionSensor { client: EufySecurity; device: eufy.Camera; - livestreamManager: LocalLivestreamManager constructor(nativeId: string, client: EufySecurity, device: eufy.Camera) { super(nativeId); this.client = client; this.device = device; - this.livestreamManager = new LocalLivestreamManager(this.client, this.device, this.console); - this.batteryLevel = this.device.getBatteryValue() as number; this.setupMotionDetection(); } @@ -37,31 +34,6 @@ class EufyCamera extends ScryptedDeviceBase implements Camera, VideoCamera, Batt this.device.on('radar motion detected', handle); } - async takePicture(options?: RequestPictureOptions): Promise { - // if this stream is prebuffered, its safe to use the prebuffer to generate an image - const realDevice = systemManager.getDeviceById(this.id); - try { - const msos = await realDevice.getVideoStreamOptions(); - const prebuffered: RequestMediaStreamOptions = msos.find(mso => mso.prebuffer); - if (prebuffered) { - prebuffered.refresh = false; - return realDevice.getVideoStream(prebuffered); - } - } catch (e) {} - - // try to fetch the cloud image if one exists - const url = this.device.getLastCameraImageURL(); - if (url) { - return mediaManager.createMediaObjectFromUrl(url.toString()); - } - - throw new Error("snapshot unavailable"); - } - - getPictureOptions(): Promise { - return; - } - getVideoStream(options?: ResponseMediaStreamOptions): Promise { return this.createVideoStream(options); } @@ -80,15 +52,32 @@ class EufyCamera extends ScryptedDeviceBase implements Camera, VideoCamera, Batt }, tool: 'scrypted', userConfigurable: false, - } + }, + { + container: 'rtsp', + id: 'p2p-low', + name: 'P2P (Low Resolution)', + video: { + codec: 'h264', + width: 1280, + height: 720, + }, + audio: { + codec: 'aac', + }, + tool: 'scrypted', + userConfigurable: false, + }, ]; } async createVideoStream(options?: ResponseMediaStreamOptions): Promise { + const livestreamManager = new LocalLivestreamManager(options.id, this.client, this.device, this.console); + const kill = new Deferred(); kill.promise.finally(() => { this.console.log('video stream exited'); - this.livestreamManager.stopLocalLiveStream(); + livestreamManager.stopLocalLiveStream(); }); const rtspServer = await listenSingleRtspClient(); @@ -138,7 +127,7 @@ class EufyCamera extends ScryptedDeviceBase implements Camera, VideoCamera, Batt await rtsp.handlePlayback(); }); - const proxyStream = await this.livestreamManager.getLocalLivestream(); + const proxyStream = await livestreamManager.getLocalLivestream(); proxyStream.videostream.pipe(process.cp.stdio[4] as Writable); proxyStream.audiostream.pipe((process.cp.stdio as any)[5] as Writable); } @@ -240,14 +229,13 @@ class EufyPlugin extends ScryptedDeviceBase implements DeviceProvider, Settings password: this.storageSettings.values.password, country: this.storageSettings.values.country, language: 'en', - p2pConnectionSetup: 2, + p2pConnectionSetup: P2PConnectionType.QUICKEST, pollingIntervalMinutes: 10, eventDurationSeconds: 10 } this.client = await EufySecurity.initialize(config); this.client.on('device added', this.deviceAdded.bind(this)); this.client.on('station added', this.stationAdded.bind(this)); - this.client.on('tfa request', () => { this.log.a('Login failed: 2FA is enabled, check your email or texts for your code, then enter it into the Two Factor Code setting to conplete login.'); }); @@ -277,7 +265,6 @@ class EufyPlugin extends ScryptedDeviceBase implements DeviceProvider, Settings const nativeId = eufyDevice.getSerial(); const interfaces = [ - ScryptedInterface.Camera, ScryptedInterface.VideoCamera ]; if (eufyDevice.hasBattery()) diff --git a/plugins/eufy/src/stream.ts b/plugins/eufy/src/stream.ts index 357a423ed..e43ffaf1f 100644 --- a/plugins/eufy/src/stream.ts +++ b/plugins/eufy/src/stream.ts @@ -1,7 +1,7 @@ // Based off of https://github.com/homebridge-eufy-security/plugin/blob/master/src/plugin/controller/LocalLivestreamManager.ts +import { Camera, CommandData, CommandName, CommandType, Device, DeviceType, EufySecurity, isGreaterEqualMinVersion, P2PClientProtocol, ParamType, Station, StreamMetadata, VideoCodec } from 'eufy-security-client'; import { EventEmitter, Readable } from 'stream'; -import { Station, Device, StreamMetadata, Camera, EufySecurity } from 'eufy-security-client'; type StationStream = { station: Station; @@ -19,28 +19,39 @@ export class LocalLivestreamManager extends EventEmitter { private livestreamStartedAt: number | null; private livestreamIsStarting = false; + private readonly id: string; private readonly client: EufySecurity; private readonly device: Camera; - constructor(client: EufySecurity, device: Camera, console: Console) { + private station: Station; + private p2pSession: P2PClientProtocol; + + constructor(id: string, client: EufySecurity, device: Camera, console: Console) { super(); + this.id = id; this.console = console; this.client = client; this.device = device; + + this.client.getStation(this.device.getStationSerial()).then( (station) => { + this.station = station; + this.p2pSession = new P2PClientProtocol(station.getRawStation(), this.client.getApi(), station.getIPAddress()); + this.p2pSession.on("livestream started", (channel: number, metadata: StreamMetadata, videostream: Readable, audiostream: Readable) => { + this.onStationLivestreamStart(station, device, metadata, videostream, audiostream); + }); + this.p2pSession.on("livestream stopped", (channel: number) => { + this.onStationLivestreamStop(station, device); + }); + this.p2pSession.on("livestream error", (channel: number, error: Error) => { + this.stopLivestream(); + }); + }); this.stationStream = null; this.livestreamStartedAt = null; this.initialize(); - - this.client.on('station livestream stop', (station: Station, device: Device) => { - this.onStationLivestreamStop(station, device); - }); - this.client.on('station livestream start', - (station: Station, device: Device, metadata: StreamMetadata, videostream: Readable, audiostream: Readable) => { - this.onStationLivestreamStart(station, device, metadata, videostream, audiostream); - }); } private initialize() { @@ -55,10 +66,10 @@ export class LocalLivestreamManager extends EventEmitter { } public async getLocalLivestream(): Promise { - this.console.debug(this.device.getName(), 'New instance requests livestream.'); + this.console.debug(this.device.getName(), this.id, 'New instance requests livestream.'); if (this.stationStream) { const runtime = (Date.now() - this.livestreamStartedAt!) / 1000; - this.console.debug(this.device.getName(), 'Using livestream that was started ' + runtime + ' seconds ago.'); + this.console.debug(this.device.getName(), this.id, 'Using livestream that was started ' + runtime + ' seconds ago.'); return this.stationStream; } else { return await this.startAndGetLocalLiveStream(); @@ -67,17 +78,17 @@ export class LocalLivestreamManager extends EventEmitter { private async startAndGetLocalLiveStream(): Promise { return new Promise((resolve, reject) => { - this.console.debug(this.device.getName(), 'Start new station livestream (P2P Session)...'); + this.console.debug(this.device.getName(), this.id, 'Start new station livestream...'); if (!this.livestreamIsStarting) { // prevent multiple stream starts from eufy station this.livestreamIsStarting = true; - this.client.startStationLivestream(this.device.getSerial()); + this.startStationLivestream(); } else { - this.console.debug(this.device.getName(), 'stream is already starting. waiting...'); + this.console.debug(this.device.getName(), this.id, 'stream is already starting. waiting...'); } this.once('livestream start', async () => { if (this.stationStream !== null) { - this.console.debug(this.device.getName(), 'New livestream started.'); + this.console.debug(this.device.getName(), this.id, 'New livestream started.'); this.livestreamIsStarting = false; resolve(this.stationStream); } else { @@ -87,15 +98,102 @@ export class LocalLivestreamManager extends EventEmitter { }); } + private async startStationLivestream(videoCodec: VideoCodec = VideoCodec.H264): Promise { + const commandData: CommandData = { + name: CommandName.DeviceStartLivestream, + value: videoCodec + }; + this.console.debug(this.device.getName(), this.id, `Sending start livestream command to station ${this.station.getSerial()}`); + const rsa_key = this.p2pSession.getRSAPrivateKey(); + + if (this.device.isSoloCameras() || this.device.getDeviceType() === DeviceType.FLOODLIGHT_CAMERA_8423 || this.device.isWiredDoorbellT8200X()) { + this.console.debug(this.device.getName(), this.id, `Using CMD_DOORBELL_SET_PAYLOAD (1) for station ${this.station.getSerial()} (main_sw_version: ${this.station.getSoftwareVersion()})`); + await this.p2pSession.sendCommandWithStringPayload({ + commandType: CommandType.CMD_DOORBELL_SET_PAYLOAD, + value: JSON.stringify({ + "commandType": ParamType.COMMAND_START_LIVESTREAM, + "data": { + "accountId": this.station.getRawStation().member.admin_user_id, + "encryptkey": rsa_key?.exportKey("components-public").n.slice(1).toString("hex"), + "streamtype": videoCodec + } + }), + channel: this.device.getChannel() + }, { + command: commandData + }); + } else if (this.device.isWiredDoorbell() || (this.device.isFloodLight() && this.device.getDeviceType() !== DeviceType.FLOODLIGHT) || this.device.isIndoorCamera() || (this.device.getSerial().startsWith("T8420") && isGreaterEqualMinVersion("2.0.4.8", this.station.getSoftwareVersion()))) { + this.console.debug(this.device.getName(), this.id, `Using CMD_DOORBELL_SET_PAYLOAD (2) for station ${this.station.getSerial()} (main_sw_version: ${this.station.getSoftwareVersion()})`); + await this.p2pSession.sendCommandWithStringPayload({ + commandType: CommandType.CMD_DOORBELL_SET_PAYLOAD, + value: JSON.stringify({ + "commandType": ParamType.COMMAND_START_LIVESTREAM, + "data": { + "account_id": this.station.getRawStation().member.admin_user_id, + "encryptkey": rsa_key?.exportKey("components-public").n.slice(1).toString("hex"), + "streamtype": videoCodec + } + }), + channel: this.device.getChannel() + }, { + command: commandData + }); + } else { + if ((Device.isIntegratedDeviceBySn(this.station.getSerial()) || !isGreaterEqualMinVersion("2.0.9.7", this.station.getSoftwareVersion())) && (!this.station.getSerial().startsWith("T8420") || !isGreaterEqualMinVersion("1.0.0.25", this.station.getSoftwareVersion()))) { + this.console.debug(this.device.getName(), this.id, `Using CMD_START_REALTIME_MEDIA for station ${this.station.getSerial()} (main_sw_version: ${this.station.getSoftwareVersion()})`); + await this.p2pSession.sendCommandWithInt({ + commandType: CommandType.CMD_START_REALTIME_MEDIA, + value: this.device.getChannel(), + strValue: rsa_key?.exportKey("components-public").n.slice(1).toString("hex"), + channel: this.device.getChannel() + }, { + command: commandData + }); + } else { + this.console.debug(this.device.getName(), this.id, `Using CMD_SET_PAYLOAD for station ${this.station.getSerial()} (main_sw_version: ${this.station.getSoftwareVersion()})`); + await this.p2pSession.sendCommandWithStringPayload({ + commandType: CommandType.CMD_SET_PAYLOAD, + value: JSON.stringify({ + "account_id": this.station.getRawStation().member.admin_user_id, + "cmd": CommandType.CMD_START_REALTIME_MEDIA, + "mValue3": CommandType.CMD_START_REALTIME_MEDIA, + "payload": { + "ClientOS": "Android", + "key": rsa_key?.exportKey("components-public").n.slice(1).toString("hex"), + "streamtype": videoCodec === VideoCodec.H264 ? 1 : 2, + } + }), + channel: this.device.getChannel() + }, { + command: commandData + }); + } + } + } + public stopLocalLiveStream(): void { - this.console.debug(this.device.getName(), 'Stopping station livestream.'); - this.client.stopStationLivestream(this.device.getSerial()); + this.console.debug(this.device.getName(), this.id, 'Stopping station livestream.'); + this.stopLivestream(); this.initialize(); } + private async stopLivestream(): Promise { + const commandData: CommandData = { + name: CommandName.DeviceStopLivestream + }; + this.console.debug(this.device.getName(), this.id, `Sending stop livestream command to station ${this.station.getSerial()}`); + await this.p2pSession.sendCommandWithInt({ + commandType: CommandType.CMD_STOP_REALTIME_MEDIA, + value: this.device.getChannel(), + channel: this.device.getChannel() + }, { + command: commandData + }); +} + private onStationLivestreamStop(station: Station, device: Device) { if (device.getSerial() === this.device.getSerial()) { - this.console.info(station.getName() + ' station livestream for ' + device.getName() + ' has stopped.'); + this.console.info(this.id + ' - ' + station.getName() + ' station livestream for ' + device.getName() + ' has stopped.'); this.initialize(); } } @@ -111,17 +209,17 @@ export class LocalLivestreamManager extends EventEmitter { if (this.stationStream) { const diff = (Date.now() - this.stationStream.createdAt) / 1000; if (diff < 5) { - this.console.warn(this.device.getName(), 'Second livestream was started from station. Ignore.'); + this.console.warn(this.device.getName(), this.id, 'Second livestream was started from station. Ignore.'); return; } } this.initialize(); // important to prevent unwanted behaviour when the eufy station emits the 'livestream start' event multiple times - this.console.info(station.getName() + ' station livestream (P2P session) for ' + device.getName() + ' has started.'); + this.console.info(this.id + ' - ' + station.getName() + ' station livestream (P2P session) for ' + device.getName() + ' has started.'); this.livestreamStartedAt = Date.now(); const createdAt = Date.now(); this.stationStream = {station, device, metadata, videostream, audiostream, createdAt}; - this.console.debug(this.device.getName(), 'Stream metadata: ' + JSON.stringify(this.stationStream.metadata)); + this.console.debug(this.device.getName(), this.id, 'Stream metadata: ' + JSON.stringify(this.stationStream.metadata)); this.emit('livestream start'); }