diff --git a/plugins/tuya/README.md b/plugins/tuya/README.md index 80cf04f6f..6a6a0ba42 100644 --- a/plugins/tuya/README.md +++ b/plugins/tuya/README.md @@ -4,7 +4,7 @@ This is a Tuya controller that integrates Tuya devices, specifically cameras, in The plugin will discover all the cameras within Tuya Cloud IoT project and report them to Scrypted, including motion events, for the ones that are supported. -## Retrieving Keys +## Requirements In order to retrieve `Access Id` and `Access Key`, you must follow the guide below: - [Using Smart Home PaaS (TuyaSmart, SmartLife, ect...)](https://developer.tuya.com/en/docs/iot/Platform_Configuration_smarthome?id=Kamcgamwoevrx&_source=6435717a3be1bc67fdd1f6699a1a59ac) @@ -12,6 +12,12 @@ In order to retrieve `Access Id` and `Access Key`, you must follow the guide bel Once you have retreived both the `Access Id` and `Access Key` from the project, you can get the `User Id` by going to Tuya Cloud IoT -> Select the Project -> Devices -> Link Tuya App Account -> and then get the UID. +You also need to enable Messages Service in your project in order to receive real time notifications to Scrypted. (motion events, online/offline, light switch ect...) The way this is achieved is by following this [guide](https://developer.tuya.com/en/docs/iot/subscribe-mq?id=Kavqcrvckbh9h). + +- You do not need to set an alert notification of your phone. +- This might not be necessary in the future if I believe MQTT is the way to go, but in the mean time, TuyaPulse is required for this project. + + ## TODOs - Fix 2-way talk for supported platforms (Can only work with WebRTC since we only get one stream with RTSPS) - Add support for camera doorbells (Just need to implement doorbell notification) \ No newline at end of file diff --git a/plugins/tuya/package-lock.json b/plugins/tuya/package-lock.json index 95f6e166f..d9f1da9c7 100644 --- a/plugins/tuya/package-lock.json +++ b/plugins/tuya/package-lock.json @@ -1,12 +1,12 @@ { "name": "@scrypted/tuya", - "version": "0.0.3", + "version": "0.0.5", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@scrypted/tuya", - "version": "0.0.3", + "version": "0.0.5", "dependencies": { "@scrypted/common": "file:../../common", "@scrypted/sdk": "file:../../sdk", diff --git a/plugins/tuya/package.json b/plugins/tuya/package.json index 7b17be51d..5298f88ba 100644 --- a/plugins/tuya/package.json +++ b/plugins/tuya/package.json @@ -43,5 +43,5 @@ "@types/uuid": "^8.3.4", "@types/ws": "^8.5.3" }, - "version": "0.0.4" + "version": "0.0.5" } diff --git a/plugins/tuya/src/camera.ts b/plugins/tuya/src/camera.ts index 8527893dc..0108a9696 100644 --- a/plugins/tuya/src/camera.ts +++ b/plugins/tuya/src/camera.ts @@ -11,7 +11,6 @@ export class TuyaCameraLight extends ScryptedDeviceBase implements OnOff { nativeId: string ) { super(nativeId); - this.updateState(); } async turnOff(): Promise { @@ -52,27 +51,32 @@ export class TuyaCameraLight extends ScryptedDeviceBase implements OnOff { } export class TuyaCamera extends ScryptedDeviceBase implements DeviceProvider, VideoCamera, BinarySensor, MotionSensor, OnOff { - cameraLight?: TuyaCameraLight + private cameraLightSwitch?: TuyaCameraLight private previousMotion?: any; private motionTimeout?: NodeJS.Timeout; constructor( public controller: TuyaController, - nativeId: string, - config: TuyaDeviceConfig + nativeId: string ) { super(nativeId); - this.updateState(config); } - // Camera Light Provider + // Camera Light Device Provider. getDevice(nativeId: string) { - if (!this.cameraLight) { - this.cameraLight = new TuyaCameraLight(this, nativeId); + // Find created devices + if (this.cameraLightSwitch?.id === nativeId) { + return this.cameraLightSwitch; } - return this.cameraLight; + // Create devices if not found. + if (nativeId === this.nativeLightSwitchId) { + this.cameraLightSwitch = new TuyaCameraLight(this, nativeId); + return this.cameraLightSwitch; + } + + throw new Error("This Camera Device Provider has not been implemented of type: " + nativeId.split('-')[1]); } // OnOff Status Indicator @@ -153,8 +157,7 @@ export class TuyaCamera extends ScryptedDeviceBase implements DeviceProvider, Vi codec: 'pcm_ulaw' }, source: 'cloud', - tool: 'scrypted', - userConfigurable: false + tool: 'scrypted' } ]; } @@ -199,24 +202,32 @@ export class TuyaCamera extends ScryptedDeviceBase implements DeviceProvider, Vi return; } - this.on = TuyaDevice.getStatusIndicator(camera)?.value; + this.online = camera.online; - const hasMotionSwitchStatus = TuyaDevice.getMotionSwitch(camera) !== undefined; - if (hasMotionSwitchStatus) { - const movementDetectedStatus = TuyaDevice.getMotionDetectionStatus(camera); - if (movementDetectedStatus) { + if (TuyaDevice.hasStatusIndicator(camera)) { + this.on = TuyaDevice.getStatusIndicator(camera)?.value; + } + + if (TuyaDevice.hasMotionDetection(camera)) { + const motionDetectedStatus = TuyaDevice.getMotionDetectionStatus(camera); + if (motionDetectedStatus) { if (!this.previousMotion) { - this.previousMotion = movementDetectedStatus.value; - } else if (this.previousMotion !== movementDetectedStatus.value) { - this.previousMotion = movementDetectedStatus.value; + this.previousMotion = motionDetectedStatus.value; + } else if (this.previousMotion !== motionDetectedStatus.value) { + this.previousMotion = motionDetectedStatus.value; this.triggerMotion(); } } } - this.getDevice(this.nativeLightId).updateState(camera); + + // By the time this is called, scrypted would have already reported the device + // Only set light switch on cameras that have a status light indicator. + if (TuyaDevice.hasLightSwitch(camera)) { + this.getDevice(this.nativeLightSwitchId)?.updateState(camera); + } } - private get nativeLightId(): string { + private get nativeLightSwitchId(): string { return `${this.nativeId}-light`; } diff --git a/plugins/tuya/src/main.ts b/plugins/tuya/src/main.ts index f753a49ac..32ffa51a6 100644 --- a/plugins/tuya/src/main.ts +++ b/plugins/tuya/src/main.ts @@ -11,69 +11,103 @@ import { TuyaPulsar, TuyaPulsarMessage } from './tuya/pulsar'; const { deviceManager } = sdk; export class TuyaController extends ScryptedDeviceBase implements DeviceProvider, DeviceDiscovery, Settings { - cloud?: TuyaCloud; - pulsar?: TuyaPulsar; + cloud: TuyaCloud; + pulsar: TuyaPulsar; cameras: Map = new Map(); - settingsStorage = new StorageSettings(this, { - userId: { - title: 'User Id', - description: 'Required: You can find this information in Tuya IoT -> Cloud -> Devices -> Linked Devices.', - onPut: async () => this.discoverDevices(0), - }, - accessId: { - title: 'Access Id', - description: 'Requirerd: This is located on the main project.', - onPut: async () => this.discoverDevices(0), - }, - accessKey: { - title: 'Access Key/Secret', - description: 'Requirerd: This is located on the main project.', - type: 'password', - onPut: async () => this.discoverDevices(0), - }, - country: { - title: 'Country', - description: 'Required: This is the country where you registered your devices.', - type: 'string', - choices: TUYA_COUNTRIES.map(value => value.country), - onPut: async () => this.discoverDevices(0) - } - }); - constructor(nativeId?: string) { super(nativeId); this.discoverDevices(0); } - async tryLogin() { - const userId = this.settingsStorage.getItem('userId'); - const accessId = this.settingsStorage.getItem('accessId'); - const accessKey = this.settingsStorage.getItem('accessKey'); - const country = TUYA_COUNTRIES.find(value => value.country == this.settingsStorage.getItem('country')); + private handlePulsarMessage(message: TuyaPulsarMessage) { + const data = message.payload.data; + const { devId, productKey } = data; - if (!userId || - !accessId || - !accessKey || - !country - ) { - this.log.a('Enter your Tuya User Id, access Id, access key, and country to complete the setup.'); - throw new Error('User Id, access Id, access key, and country info are missing.'); + const device = this.cloud?.cameras?.find(c => c.id === devId); + + if (data.bizCode) { + if (device && (data.bizCode === 'online' || data.bizCode === 'offline')) { + // Device status changed + const isOnline = data.bizCode === 'online'; + device.online = isOnline; + return this.cameras.get(devId); + } else if (device && data.bizCode === 'delete') { + // Device needs to be deleted + // - devId + // - uid + + const { uid } = data.bizData; + // TODO: delete device + } else if (data.bizCode === 'add') { + // TODO: There is a new device added, refetch + } + } else { + if (!device) { + return; + } + + const newStatus = data.status || []; + + newStatus.forEach(item => { + const index = device.status.findIndex(status => status.code == item.code); + if (index !== -1) { + device.status[index].value = item.value + } + }); + + return this.cameras.get(devId); + } + } + + async discoverDevices(duration: number) { + const userId = this.getSetting('userId'); + const accessId = this.getSetting('accessId'); + const accessKey = this.getSetting('accessKey'); + const country = TUYA_COUNTRIES.find(value => value.country == this.getSetting('country')); + + this.log.clearAlerts(); + + let missingItems: string[] = []; + + if (!userId) + missingItems.push('User Id'); + + if (!accessId) + missingItems.push('Access Id'); + + if (!accessKey) + missingItems.push('Access Key'); + + if (!country) + missingItems.push('Country'); + + if (missingItems.length > 0) { + this.log.a(`You must provide your ${missingItems.join(', ')}.`); + return; } - this.cloud = new TuyaCloud( - userId, - accessId, - accessKey, - country - ); + if (!this.cloud) { + this.cloud = new TuyaCloud( + userId, + accessId, + accessKey, + country + ); + } - const success = await this.cloud.login(); + // If it cannot fetch devices, then that means it's permission denied. + // For some reason, when generating a token does not validate authorization. + if (!await this.cloud.fetchDevices()) { + this.log.a("Failed to log in with credentials. Please try again."); + this.cloud = null; + return; + } - if (!success) { - this.log.e("Failed to log in with credentials."); - this.cloud = undefined; - throw new Error("Failed to log in with credentials, please check if everything is correct."); + this.log.a("Successsfully logged in with credentials! Now discovering devices."); + + if (this.pulsar) { + this.pulsar.stop(); } this.pulsar = new TuyaPulsar({ @@ -83,13 +117,13 @@ export class TuyaController extends ScryptedDeviceBase implements DeviceProvider }); this.pulsar.open(() => { - this.log.i(`TulsaPulse: opening connection.`) + this.log.i(`TulsaPulse: opened connection.`) }); this.pulsar.message((ws, message) => { this.pulsar?.ackMessage(message.messageId); this.log.i(`TuyaPulse: message received: ${message}`); - const tuyaDevice = handleMessage(message); + const tuyaDevice = this.handlePulsarMessage(message); if (!tuyaDevice) return; tuyaDevice.updateState(); @@ -107,78 +141,19 @@ export class TuyaController extends ScryptedDeviceBase implements DeviceProvider this.log.e(`TuyaPulse: ${error}`); }); + this.pulsar.maxRetries(() => { + this.log.e("There was an error trying to connect to Message Service (TuyaPulse). Connection Max Reconnection Timed Out"); + }); + this.pulsar.start(); - const handleMessage = (message: TuyaPulsarMessage) => { - const data = message.payload.data; - const { devId, productKey } = data; - - const device = this.cloud?.cameras?.find(c => c.id === devId); - - if (data.bizCode) { - if (device && (data.bizCode === 'online' || data.bizCode === 'offline')) { - // Device status changed - const isOnline = data.bizCode === 'online'; - device.online = isOnline; - return this.cameras.get(devId); - } else if (device && data.bizCode === 'delete') { - // Device needs to be deleted - // - devId - // - uid - - const { uid } = data.bizData; - } else if (data.bizCode === 'add') { - // TODO: There is a new device added, refetch - } - } else { - if (!device) { - return; - } - - const newStatus = data.status || []; - - newStatus.forEach(item => { - const index = device.status.findIndex(status => status.code == item.code); - if (index !== -1) { - device.status[index].value = item.value - } - }); - - return this.cameras.get(devId); - } - } - } - - getSettings(): Promise { - return this.settingsStorage.getSettings(); - } - - putSetting(key: string, value: SettingValue): Promise { - return this.settingsStorage.putSetting(key, value); - } - - async discoverDevices(duration: number) { - await this.tryLogin(); - - this.log.clearAlerts(); - this.log.a("Successsfully logged in with credentials! Now discovering devices."); - - const cloud = this.cloud; - - if (!cloud) { - throw new Error("There was an error: TuyaCloud not initialized"); - } - - if (!await cloud.fetchDevices()) { - this.log.e("Could not fetch devices."); - throw new Error("There was an error fetching devices."); - } + // Find devices const devices: Device[] = []; // Camera Setup - for (const camera of cloud.cameras || []) { + for (const camera of this.cloud.cameras || []) { const nativeId = camera.id; const device: Device = { @@ -226,10 +201,9 @@ export class TuyaController extends ScryptedDeviceBase implements DeviceProvider // Handle any camera device that have a light switch - for (const camera of cloud.cameras || []) { - if (!TuyaDevice.hasLightSwitch(camera)) { + for (const camera of this.cloud.cameras || []) { + if (!TuyaDevice.hasLightSwitch(camera)) continue; - } const nativeId = camera.id + '-light'; const device: Device = { providerNativeId: camera.id, @@ -252,7 +226,7 @@ export class TuyaController extends ScryptedDeviceBase implements DeviceProvider }); } - // Update devices with new state + // Update devices with new state for (const device of devices) { await this.getDevice(device.nativeId).then(device => device?.updateState()); @@ -266,13 +240,56 @@ export class TuyaController extends ScryptedDeviceBase implements DeviceProvider const camera = this.cloud?.cameras?.find(camera => camera.id === nativeId); if (camera) { - const ret = new TuyaCamera(this, nativeId, camera); + const ret = new TuyaCamera(this, nativeId); this.cameras.set(nativeId, ret); return ret; } throw new Error('device not found?'); } + + // Settings + + async getSettings(): Promise { + return [ + { + key: 'userId', + title: 'User Id', + description: 'Required: You can find this information in Tuya IoT -> Cloud -> Devices -> Linked Devices.', + value: this.getSetting('userId') + }, + { + key: 'accessId', + title: 'Access Id', + description: 'Requirerd: This is located on the main project.', + value: this.getSetting('accessId') + }, + { + key: 'accessKey', + title: 'Access Key/Secret', + description: 'Requirerd: This is located on the main project.', + type: 'password', + value: this.getSetting('accessKey') + }, + { + key: 'country', + title: 'Country', + description: 'Required: This is the country where you registered your devices.', + type: 'string', + choices: TUYA_COUNTRIES.map(value => value.country), + value: this.getSetting('country') + } + ] + } + + getSetting(key: string): string | null { + return this.storage.getItem(key); + } + + async putSetting(key: string, value: string): Promise { + this.storage.setItem(key, value); + this.discoverDevices(0); + } } export default createInstanceableProviderPlugin("Tuya", nativeId => new TuyaController(nativeId)); diff --git a/plugins/tuya/src/tuya/cloud.ts b/plugins/tuya/src/tuya/cloud.ts index e7bc0ce99..fccc3347e 100644 --- a/plugins/tuya/src/tuya/cloud.ts +++ b/plugins/tuya/src/tuya/cloud.ts @@ -40,7 +40,6 @@ export class TuyaCloud { public async login(): Promise { await this.refreshAccessTokenIfNeeded(); - return this.isLoggedIn(); } @@ -144,9 +143,12 @@ export class TuyaCloud { query: { [k: string]: any } = {}, body: { [k: string]: any } = {} ): Promise> { - await this.refreshAccessTokenIfNeeded(); - if (!this.session) { - throw new Error(`Token session not available for TuyaCloud.`); + if (!await this.login()) { + return { + result: undefined, + success: false, + t: Date.now() + } } const timestamp = Date.now().toString(); @@ -229,15 +231,25 @@ export class TuyaCloud { { headers } ); - let objData = JSON.parse(data); + interface Token { + access_token: string; + refresh_token: string; + expire_time: number; + uid: string; + } - const newExpiration = new Date(Date.now() + objData.result.expire_time * 1000); + let response: TuyaResponse = JSON.parse(data); - this.session = { - accessToken: objData.result.access_token, - refreshToken: objData.result.refresh_token, - tokenExpiresAt: newExpiration, - uid: objData.result.uid - }; + if (!response.success) { + this.session = undefined; + } else { + const newExpiration = new Date(Date.now() + response.result.expire_time * 1000); + this.session = { + accessToken: response.result.access_token, + refreshToken: response.result.refresh_token, + tokenExpiresAt: newExpiration, + uid: response.result.uid + }; + } } } \ No newline at end of file diff --git a/plugins/tuya/src/tuya/device.ts b/plugins/tuya/src/tuya/device.ts index 125994afd..f6936729b 100644 --- a/plugins/tuya/src/tuya/device.ts +++ b/plugins/tuya/src/tuya/device.ts @@ -40,15 +40,12 @@ export namespace TuyaDevice { // MARK: Motion Detection export function hasMotionDetection(camera: TuyaDeviceConfig): boolean { - return getMotionSwitch(camera) !== undefined; - } - - export function getMotionSwitch(camera: TuyaDeviceConfig) { const motionSwitchCodes = [ 'motion_switch', 'pir_sensitivity' ] - return getStatus(camera, motionSwitchCodes); + + return getStatus(camera, motionSwitchCodes) !== undefined; } export function getMotionDetectionStatus(camera: TuyaDeviceConfig) { diff --git a/plugins/tuya/src/tuya/pulsar.ts b/plugins/tuya/src/tuya/pulsar.ts index c03bd93c9..2017973bf 100644 --- a/plugins/tuya/src/tuya/pulsar.ts +++ b/plugins/tuya/src/tuya/pulsar.ts @@ -51,6 +51,7 @@ export class TuyaPulsar { static reconnect = 'TUYA_RECONNECT'; static ping = 'TUYA_PING'; static pong = 'TUYA_PONG'; + static maxRetries = 'TUYA_MAXRETRIES'; private config: IConfig; private server?: WebSocket; @@ -64,7 +65,7 @@ export class TuyaPulsar { ackTimeoutMillis: 3000, subscriptionType: 'Failover', retryTimeout: 1000, - maxRetryTimes: 100, + maxRetryTimes: 10, timeout: 30000, logger: console.log, }, @@ -114,6 +115,10 @@ export class TuyaPulsar { this.event.on(TuyaPulsar.close, cb); } + public maxRetries(cb: () => void) { + this.event.on(TuyaPulsar.maxRetries, cb); + } + private _reconnect() { if (this.config.maxRetryTimes && this.retryTimes < this.config.maxRetryTimes) { const timer = setTimeout(() => { @@ -121,6 +126,9 @@ export class TuyaPulsar { this.retryTimes++; this._connect(false); }, this.config.retryTimeout); + } else { + this.clearKeepAlive(); + this.event.emit(TuyaPulsar.maxRetries); } }