[Tuya Plugin] Fixed issue with devices not loading (#355)

* Fixed issue with devices not loading
- Updated readme with requirements.
- fixed race conditions
- reorganized main.ts
- improved cloud login verification
- fixed issue where cameras with no light switch was forced to add a light, whic caused to crash plugin.

* updated version to v0.0.5
This commit is contained in:
Erik Bautista
2022-08-26 22:02:41 -05:00
committed by GitHub
parent a3e1793533
commit a2b33405cb
8 changed files with 217 additions and 166 deletions

View File

@@ -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)

View File

@@ -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",

View File

@@ -43,5 +43,5 @@
"@types/uuid": "^8.3.4",
"@types/ws": "^8.5.3"
},
"version": "0.0.4"
"version": "0.0.5"
}

View File

@@ -11,7 +11,6 @@ export class TuyaCameraLight extends ScryptedDeviceBase implements OnOff {
nativeId: string
) {
super(nativeId);
this.updateState();
}
async turnOff(): Promise<void> {
@@ -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`;
}

View File

@@ -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<string, TuyaCamera> = 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<Setting[]> {
return this.settingsStorage.getSettings();
}
putSetting(key: string, value: SettingValue): Promise<void> {
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<Setting[]> {
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<void> {
this.storage.setItem(key, value);
this.discoverDevices(0);
}
}
export default createInstanceableProviderPlugin("Tuya", nativeId => new TuyaController(nativeId));

View File

@@ -40,7 +40,6 @@ export class TuyaCloud {
public async login(): Promise<boolean> {
await this.refreshAccessTokenIfNeeded();
return this.isLoggedIn();
}
@@ -144,9 +143,12 @@ export class TuyaCloud {
query: { [k: string]: any } = {},
body: { [k: string]: any } = {}
): Promise<TuyaResponse<T>> {
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<Token> = 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
};
}
}
}

View File

@@ -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) {

View File

@@ -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);
}
}