eufy: add plugin (#614)

This commit is contained in:
Alex Leeds
2023-03-09 17:13:03 -05:00
committed by GitHub
parent ebf2176618
commit d5c90ab8da
11 changed files with 2080 additions and 0 deletions

4
plugins/eufy/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.DS_Store
out/
node_modules/
dist/

11
plugins/eufy/.npmignore Normal file
View File

@@ -0,0 +1,11 @@
.DS_Store
out/
node_modules/
*.map
fs
src
.vscode
dist/*.js
dist/*.txt
HAP-NodeJS
.gitattributes

23
plugins/eufy/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Scrypted Debugger",
"address": "${config:scrypted.debugHost}",
"port": 10081,
"request": "attach",
"skipFiles": [
"**/plugin-remote-worker.*",
"<node_internals>/**"
],
"preLaunchTask": "scrypted: deploy+debug",
"sourceMaps": true,
"localRoot": "${workspaceFolder}/out",
"remoteRoot": "/plugin/",
"type": "pwa-node"
}
]
}

3
plugins/eufy/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"scrypted.debugHost": "127.0.0.1",
}

20
plugins/eufy/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "scrypted: deploy+debug",
"type": "shell",
"presentation": {
"echo": true,
"reveal": "silent",
"focus": false,
"panel": "shared",
"showReuseMessage": true,
"clear": false
},
"command": "npm run scrypted-vscode-launch ${config:scrypted.debugHost}",
},
]
}

1
plugins/eufy/README.md Normal file
View File

@@ -0,0 +1 @@
# Eufy Plugin for Scrypted

1319
plugins/eufy/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
plugins/eufy/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "@scrypted/eufy",
"description": "Eufy Plugin for Scrypted",
"version": "0.0.1",
"keywords": [
"scrypted",
"plugin",
"eufy",
"camera"
],
"scripts": {
"build": "scrypted-webpack",
"prepublishOnly": "NODE_ENV=production scrypted-webpack",
"prescrypted-vscode-launch": "scrypted-webpack",
"scrypted-vscode-launch": "scrypted-deploy-debug",
"scrypted-deploy-debug": "scrypted-deploy-debug",
"scrypted-debug": "scrypted-debug",
"scrypted-deploy": "scrypted-deploy",
"scrypted-readme": "scrypted-readme",
"scrypted-package-json": "scrypted-package-json",
"scrypted-webpack": "scrypted-webpack"
},
"scrypted": {
"name": "Eufy",
"type": "DeviceProvider",
"interfaces": [
"DeviceProvider",
"Settings"
]
},
"dependencies": {
"@scrypted/sdk": "file:../../sdk",
"@scrypted/common": "file:../../common",
"@types/node": "^18.14.6"
},
"optionalDependencies": {
"eufy-security-client": "^2.4.2"
}
}

203
plugins/eufy/src/main.ts Normal file
View File

@@ -0,0 +1,203 @@
import sdk, { Battery, Camera, Device, DeviceProvider, FFmpegInput, MediaObject, RequestPictureOptions, ResponseMediaStreamOptions, ResponsePictureOptions, ScryptedDeviceBase, ScryptedDeviceType, ScryptedInterface, Setting, Settings, SettingValue, VideoCamera } from '@scrypted/sdk';
import { StorageSettings } from '@scrypted/sdk/storage-settings';
import eufy, { EufySecurity } from 'eufy-security-client';
import { LocalLivestreamManager } from './stream';
import { listenZeroSingleClient } from '@scrypted/common/src/listen-cluster';
const { deviceManager, mediaManager } = sdk;
class EufyCamera extends ScryptedDeviceBase implements Camera, VideoCamera, Battery {
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, false, this.console);
// this.batteryLevel = this.device.getBatteryValue() as number;
}
takePicture(options?: RequestPictureOptions): Promise<MediaObject> {
const url = this.device.getLastCameraImageURL();
return mediaManager.createMediaObjectFromUrl(url.toString());
}
getPictureOptions(): Promise<ResponsePictureOptions[]> {
return;
}
getVideoStream(options?: ResponseMediaStreamOptions): Promise<MediaObject> {
return this.createVideoStream(options);
}
async getVideoStreamOptions(): Promise<ResponseMediaStreamOptions[]> {
return [
{
id: 'p2p',
name: 'P2P',
video: {
codec: 'h264',
},
source: 'cloud',
tool: 'ffmpeg',
userConfigurable: false,
}
];
}
async createVideoStream(options?: ResponseMediaStreamOptions): Promise<MediaObject> {
const tcp = await listenZeroSingleClient();
const proxyStream = await this.livestreamManager.getLocalLivestream();
tcp.clientPromise.then(socket => {
proxyStream.videostream.pipe(socket);
});
const input: FFmpegInput = {
url: undefined,
inputArguments:[
'-f',
'h264',
'-i',
tcp.url
],
mediaStreamOptions: options,
};
return mediaManager.createFFmpegMediaObject(input);
}
}
class EufyPlugin extends ScryptedDeviceBase implements DeviceProvider, Settings {
client: EufySecurity;
devices = new Map<string, any>();
storageSettings = new StorageSettings(this, {
country: {
title: 'Country',
defaultValue: 'US',
},
email: {
title: 'Email',
onPut: async () => this.tryLogin(),
},
password: {
title: 'Password',
type: 'password',
onPut: async () => this.tryLogin(),
},
twoFactorCode: {
title: 'Two Factor Code',
description: 'Optional: If 2FA is enabled on your account, enter the code sent to your email or phone number.',
onPut: async (oldValue, newValue) => {
await this.tryLogin(newValue);
},
noStore: true,
},
});
constructor() {
super();
this.tryLogin()
}
getSettings(): Promise<Setting[]> {
return this.storageSettings.getSettings();
}
putSetting(key: string, value: SettingValue): Promise<void> {
return this.storageSettings.putSetting(key, value);
}
async tryLogin(twoFactorCode?: string) {
this.log.clearAlerts();
if (!this.storageSettings.values.email || !this.storageSettings.values.email) {
this.log.a('Enter your Eufy email and password to complete setup.');
throw new Error('Eufy email and password are missing.');
}
await this.initializeClient();
try {
await this.client.connect({verifyCode: twoFactorCode, force: false});
this.console.debug(`[${this.name}] (${new Date().toLocaleString()}) Client connected.`);
} catch (e) {
this.log.a('Login failed: if you have 2FA enabled, check your email or texts for your code, then enter it into the Two Factor Code setting to conplete login.');
this.console.error(`[${this.name}] (${new Date().toLocaleString()}) Client failed to connect.`, e);
}
}
private async initializeClient() {
const config = {
username: this.storageSettings.values.email,
password: this.storageSettings.values.password,
country: this.storageSettings.values.country,
language: 'en',
p2pConnectionSetup: 2,
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('push connect', () => {
this.console.log(`[${this.name}] (${new Date().toLocaleString()}) Push Connected.`);
});
this.client.on('push close', () => {
this.console.log(`[${this.name}] (${new Date().toLocaleString()}) Push Closed.`);
});
}
private async deviceAdded(eufyDevice: eufy.Device) {
if (!eufyDevice.isCamera) {
this.console.info(`[${this.name}] (${new Date().toLocaleString()}) Ignoring unsupported discovered device: `, eufyDevice.getName(), eufyDevice.getModel());
return;
}
this.console.info(`[${this.name}] (${new Date().toLocaleString()}) Device discovered: `, eufyDevice.getName(), eufyDevice.getModel());
const nativeId = eufyDevice.getSerial();
const interfaces = [
ScryptedInterface.Camera,
ScryptedInterface.VideoCamera
];
if (eufyDevice.hasBattery())
interfaces.push(ScryptedInterface.Battery);
const device: Device = {
info: {
model: eufyDevice.getModel(),
manufacturer: 'Eufy',
firmware: eufyDevice.getSoftwareVersion(),
serialNumber: nativeId
},
nativeId,
name: eufyDevice.getName(),
type: ScryptedDeviceType.Camera,
interfaces,
};
this.devices.set(nativeId, new EufyCamera(nativeId, this.client, eufyDevice as eufy.Camera))
await deviceManager.onDeviceDiscovered(device);
}
private async stationAdded(station: eufy.Station) {
this.console.info(`[${this.name}] (${new Date().toLocaleString()}) Station discovered: `, station.getName(), station.getModel(), `but stations are not currently supported.`);
}
async getDevice(nativeId: string): Promise<any> {
return this.devices.get(nativeId);
}
async releaseDevice(id: string, nativeId: string) {
this.console.info(`[${this.name}] (${new Date().toLocaleString()}) Device with id '${nativeId}' was removed.`);
}
}
export default new EufyPlugin();

444
plugins/eufy/src/stream.ts Normal file
View File

@@ -0,0 +1,444 @@
// Based off of https://github.com/homebridge-eufy-security/plugin/blob/master/src/plugin/controller/LocalLivestreamManager.ts
import { EventEmitter, Readable } from 'stream';
import { Station, Device, StreamMetadata, Camera, EufySecurity } from 'eufy-security-client';
import path from 'path';
type StationStream = {
station: Station;
device: Device;
metadata: StreamMetadata;
videostream: Readable;
audiostream: Readable;
createdAt: number;
};
class AudiostreamProxy extends Readable {
private console: Console;
private cacheData: Array<Buffer> = [];
private pushNewDataImmediately = false;
private dataFramesCount = 0;
constructor(console: Console) {
super();
this.console = console;
}
private transmitData(data: Buffer | undefined): boolean {
this.dataFramesCount++;
return this.push(data);
}
public newAudioData(data: Buffer): void {
if (this.pushNewDataImmediately) {
this.pushNewDataImmediately = false;
this.transmitData(data);
} else {
this.cacheData.push(data);
}
}
public stopProxyStream(): void {
this.console.debug('Audiostream was stopped after transmission of ' + this.dataFramesCount + ' data chunks.');
this.unpipe();
this.destroy();
}
_read(size: number): void {
let pushReturn = true;
while (this.cacheData.length > 0 && pushReturn) {
const data = this.cacheData.shift();
pushReturn = this.transmitData(data);
}
if (pushReturn) {
this.pushNewDataImmediately = true;
}
}
}
class VideostreamProxy extends Readable {
private manager: LocalLivestreamManager;
private livestreamId: number;
private cacheData: Array<Buffer> = [];
private console: Console;
private killTimeout: NodeJS.Timeout | null = null;
private pushNewDataImmediately = false;
private dataFramesCount = 0;
constructor(id: number, cacheData: Array<Buffer>, manager: LocalLivestreamManager, console: Console) {
super();
this.livestreamId = id;
this.manager = manager;
this.cacheData = cacheData;
this.console = console;
this.resetKillTimeout();
}
private transmitData(data: Buffer | undefined): boolean {
this.dataFramesCount++;
return this.push(data);
}
public newVideoData(data: Buffer): void {
if (this.pushNewDataImmediately) {
this.pushNewDataImmediately = false;
try {
if(this.transmitData(data)) {
this.resetKillTimeout();
}
} catch (err) {
this.console.debug('Push of new data was not succesful. Most likely the target process (ffmpeg) was already terminated. Error: ' + err);
}
} else {
this.cacheData.push(data);
}
}
public stopProxyStream(): void {
this.console.debug('Videostream was stopped after transmission of ' + this.dataFramesCount + ' data chunks.');
this.unpipe();
this.destroy();
if (this.killTimeout) {
clearTimeout(this.killTimeout);
}
}
private resetKillTimeout(): void {
if (this.killTimeout) {
clearTimeout(this.killTimeout);
}
this.killTimeout = setTimeout(() => {
this.console.warn('Proxy Stream (id: ' + this.livestreamId + ') was terminated due to inactivity. (no data transmitted in 15 seconds)');
this.manager.stopProxyStream(this.livestreamId);
}, 15000);
}
_read(size: number): void {
this.resetKillTimeout();
let pushReturn = true;
while (this.cacheData.length > 0 && pushReturn) {
const data = this.cacheData.shift();
pushReturn = this.transmitData(data);
}
if (pushReturn) {
this.pushNewDataImmediately = true;
}
}
}
type ProxyStream = {
id: number;
videostream: VideostreamProxy;
audiostream: AudiostreamProxy;
};
export class LocalLivestreamManager extends EventEmitter {
private readonly SECONDS_UNTIL_TERMINATION_AFTER_LAST_USED = 45;
private readonly CONNECTION_ESTABLISHED_TIMEOUT = 5;
private stationStream: StationStream | null;
private console: Console;
private livestreamCount = 1;
private iFrameCache: Array<Buffer> = [];
private proxyStreams: Set<ProxyStream> = new Set<ProxyStream>();
private cacheEnabled: boolean;
private connectionTimeout?: NodeJS.Timeout;
private terminationTimeout?: NodeJS.Timeout;
private livestreamStartedAt: number | null;
private livestreamIsStarting = false;
private readonly client: EufySecurity;
private readonly device: Camera;
constructor(client: EufySecurity, device: Camera, cacheEnabled: boolean, console: Console) {
super();
this.console = console;
this.client = client;
this.device = device;
this.cacheEnabled = cacheEnabled;
if (this.cacheEnabled) {
this.console.debug('Livestream caching for ' + this.device.getName() + ' is enabled.');
}
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() {
if (this.stationStream) {
this.stationStream.audiostream.unpipe();
this.stationStream.audiostream.destroy();
this.stationStream.videostream.unpipe();
this.stationStream.videostream.destroy();
}
this.stationStream = null;
this.iFrameCache = [];
this.livestreamStartedAt = null;
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
}
}
public async getLocalLivestream(): Promise<ProxyStream> {
this.console.debug(this.device.getName(), 'New instance requests livestream. There were ' +
this.proxyStreams.size + ' instance(s) using the livestream until now.');
if (this.terminationTimeout) {
clearTimeout(this.terminationTimeout);
}
const proxyStream = await this.getProxyStream();
if (proxyStream) {
const runtime = (Date.now() - this.livestreamStartedAt!) / 1000;
this.console.debug(
this.device.getName(),
'Using livestream that was started ' + runtime + ' seconds ago. The proxy stream has id: ' + proxyStream.id + '.');
return proxyStream;
} else {
return await this.startAndGetLocalLiveStream();
}
}
private async startAndGetLocalLiveStream(): Promise<ProxyStream> {
return new Promise((resolve, reject) => {
this.console.debug(this.device.getName(), 'Start new station livestream (P2P Session)...');
if (!this.livestreamIsStarting) { // prevent multiple stream starts from eufy station
this.livestreamIsStarting = true;
this.client.startStationLivestream(this.device.getSerial());
} else {
this.console.debug(this.device.getName(), 'stream is already starting. waiting...');
}
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
}
this.connectionTimeout = setTimeout(() => {
this.livestreamIsStarting = false;
this.console.error(this.device.getName(), 'Local livestream didn\'t start in time. Abort livestream request.');
reject('no started livestream found');
}, this.CONNECTION_ESTABLISHED_TIMEOUT * 2000);
this.once('livestream start', async () => {
if (this.connectionTimeout) {
clearTimeout(this.connectionTimeout);
}
const proxyStream = await this.getProxyStream();
if (proxyStream !== null) {
this.console.debug(this.device.getName(), 'New livestream started. Proxy stream has id: ' + proxyStream.id + '.');
this.livestreamIsStarting = false;
resolve(proxyStream);
} else {
reject('no started livestream found');
}
});
});
}
private scheduleLivestreamCacheTermination(streamingTimeLeft: number): void {
// eslint-disable-next-line max-len
const terminationTime = ((streamingTimeLeft - this.SECONDS_UNTIL_TERMINATION_AFTER_LAST_USED) > 20) ? this.SECONDS_UNTIL_TERMINATION_AFTER_LAST_USED : streamingTimeLeft - 20;
this.console.debug(
this.device.getName(),
'Schedule livestream termination in ' + terminationTime + ' seconds.');
if (this.terminationTimeout) {
clearTimeout(this.terminationTimeout);
}
this.terminationTimeout = setTimeout(() => {
if (this.proxyStreams.size <= 0) {
this.stopLocalLiveStream();
}
}, terminationTime * 1000);
}
public stopLocalLiveStream(): void {
this.console.debug(this.device.getName(), 'Stopping station livestream.');
this.client.stopStationLivestream(this.device.getSerial());
this.initialize();
}
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.proxyStreams.forEach((proxyStream) => {
proxyStream.audiostream.stopProxyStream();
proxyStream.videostream.stopProxyStream();
this.removeProxyStream(proxyStream.id);
});
this.initialize();
}
}
private async onStationLivestreamStart(
station: Station,
device: Device,
metadata: StreamMetadata,
videostream: Readable,
audiostream: Readable,
) {
if (device.getSerial() === this.device.getSerial()) {
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.');
return;
}
}
this.initialize(); // important to prevent unwanted behaviour when the eufy station emits the 'livestream start' event multiple times
videostream.on('data', (data) => {
if(this.isIFrame(data)) { // cache iFrames to speed up livestream encoding
this.iFrameCache = [data];
} else if (this.iFrameCache.length > 0) {
this.iFrameCache.push(data);
}
this.proxyStreams.forEach((proxyStream) => {
proxyStream.videostream.newVideoData(data);
});
});
videostream.on('error', (error) => {
this.console.error(this.device.getName(), 'Local videostream had Error: ' + error);
this.stopAllProxyStreams();
this.stopLocalLiveStream();
});
videostream.on('end', () => {
this.console.debug(this.device.getName(), 'Local videostream has ended. Clean up.');
this.stopAllProxyStreams();
this.stopLocalLiveStream();
});
audiostream.on('data', (data) => {
this.proxyStreams.forEach((proxyStream) => {
proxyStream.audiostream.newAudioData(data);
});
});
audiostream.on('error', (error) => {
this.console.error(this.device.getName(), 'Local audiostream had Error: ' + error);
this.stopAllProxyStreams();
this.stopLocalLiveStream();
});
audiostream.on('end', () => {
this.console.debug(this.device.getName(), 'Local audiostream has ended. Clean up.');
this.stopAllProxyStreams();
this.stopLocalLiveStream();
});
this.console.info(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.emit('livestream start');
}
}
private getProxyStream(): ProxyStream | null {
if (this.stationStream) {
const id = this.livestreamCount;
this.livestreamCount++;
if (this.livestreamCount > 1024) {
this.livestreamCount = 1;
}
const videostream = new VideostreamProxy(id, this.iFrameCache, this, this.console);
const audiostream = new AudiostreamProxy(this.console);
const proxyStream = { id, videostream, audiostream };
this.proxyStreams.add(proxyStream);
return proxyStream;
} else {
return null;
}
}
public stopProxyStream(id: number): void {
this.proxyStreams.forEach((pStream) => {
if (pStream.id === id) {
pStream.audiostream.stopProxyStream();
pStream.videostream.stopProxyStream();
this.removeProxyStream(id);
}
});
}
private stopAllProxyStreams(): void {
this.proxyStreams.forEach((proxyStream) => {
this.stopProxyStream(proxyStream.id);
});
}
private removeProxyStream(id: number): void {
let proxyStream: ProxyStream | null = null;
this.proxyStreams.forEach((pStream) => {
if (pStream.id === id) {
proxyStream = pStream;
}
});
if (proxyStream !== null) {
this.proxyStreams.delete(proxyStream);
this.console.debug(this.device.getName(), 'One stream instance (id: ' + id + ') released livestream. There are now ' +
this.proxyStreams.size + ' instance(s) using the livestream.');
if(this.proxyStreams.size === 0) {
this.console.debug(this.device.getName(), 'All proxy instances to the livestream have terminated.');
// check if minimum remaining livestream duration is more than 20 percent
// of maximum streaming duration or at least 20 seconds
// if so the termination of the livestream is scheduled
// if a new livestream is initiated in that time (e.g. fetching a snapshot)
// the cached livestream can be used
// caching must also be enabled of course
const maxStreamingDuration = this.client.getCameraMaxLivestreamDuration();
const runtime = (Date.now() - ((this.livestreamStartedAt !== null) ? this.livestreamStartedAt! : Date.now())) / 1000;
if (((maxStreamingDuration - runtime) > maxStreamingDuration*0.2) && (maxStreamingDuration - runtime) > 20 && this.cacheEnabled) {
this.console.debug(
this.device.getName(),
'Sufficient remaining livestream duration available. (' + (maxStreamingDuration - runtime) + ' seconds left)');
this.scheduleLivestreamCacheTermination(Math.floor(maxStreamingDuration - runtime));
} else {
// stop livestream immediately
if (this.cacheEnabled) {
this.console.debug(this.device.getName(), 'Not enough remaining livestream duration. Emptying livestream cache.');
}
this.stopLocalLiveStream();
}
}
}
}
private isIFrame(data: Buffer): boolean {
const validValues = [64, 66, 68, 78, 101, 103];
if (data !== undefined && data.length > 0) {
if (data.length >= 5) {
const startcode = [...data.slice(0, 5)];
if (validValues.includes(startcode[3]) || validValues.includes(startcode[4])) {
return true;
}
}
}
return false;
}
}

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "ES2021",
"resolveJsonModule": true,
"moduleResolution": "Node16",
"esModuleInterop": true,
"sourceMap": true
},
"include": [
"src/**/*"
]
}