Skip to content

Commit

Permalink
Merge branch 'master' into next
Browse files Browse the repository at this point in the history
  • Loading branch information
bobvandevijver committed Sep 23, 2024
2 parents 8d9390e + fabfe74 commit ebb079a
Show file tree
Hide file tree
Showing 8 changed files with 104 additions and 79 deletions.
6 changes: 6 additions & 0 deletions .homeychangelog.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,5 +67,11 @@
},
"1.3.3": {
"en": "Resolve issues with status updates"
},
"1.3.4": {
"en": "Optimise pairing process"
},
"1.3.5": {
"en": "Improve webhook data handling"
}
}
2 changes: 1 addition & 1 deletion .homeycompose/app.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"id": "com.tuya",
"version": "1.3.3",
"version": "1.3.5",
"compatibility": ">=12.0.1",
"brandColor": "#FF4800",
"sdk": 3,
Expand Down
2 changes: 1 addition & 1 deletion app.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"_comment": "This file is generated. Please edit .homeycompose/app.json instead.",
"id": "com.tuya",
"version": "1.3.3",
"version": "1.3.5",
"compatibility": ">=12.0.1",
"brandColor": "#FF4800",
"sdk": 3,
Expand Down
42 changes: 26 additions & 16 deletions app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,25 +59,29 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
};

const autocompleteListener = async (
query: string,
query: string | undefined,
args: DeviceArgs,
filter: ({ value }: { value: unknown }) => boolean,
): Promise<ArgumentAutocompleteResults> => {
function convert(
values: TuyaStatusResponse | Array<TuyaDeviceDataPoint>,
dataPoints: boolean,
): ArgumentAutocompleteResults {
return values
.filter(filter)
.filter(({ code }: { code: string }) => {
return code.toLowerCase().includes(query.toLowerCase());
})
.map(value => ({
name: value.code,
id: value.code,
title: value.code,
dataPoint: dataPoints,
}));
values = values.filter(filter);

const trimmedQuery = (query ?? '').trim();
if (trimmedQuery) {
values = values.filter(({ code }: { code: string }) =>
code.toLowerCase().includes(trimmedQuery.toLowerCase()),
);
}

return values.map(value => ({
name: value.code,
id: value.code,
title: value.code,
dataPoint: dataPoints,
}));
}

const deviceId = args.device.getData().deviceId;
Expand Down Expand Up @@ -221,7 +225,7 @@ module.exports = class TuyaOAuth2App extends OAuth2App {
const client = this.getFirstSavedOAuth2Client();
await client.triggerScene(scene.id);
})
.registerArgumentAutocompleteListener('scene', async (query: string) => {
.registerArgumentAutocompleteListener('scene', async (query?: string) => {
if (!this.apiCache.has(SCENE_CACHE_KEY)) {
this.log('Retrieving available scenes');
const client = this.getFirstSavedOAuth2Client();
Expand Down Expand Up @@ -259,9 +263,15 @@ module.exports = class TuyaOAuth2App extends OAuth2App {

this.apiCache.set(SCENE_CACHE_KEY, scenes);
}
return (this.apiCache.get<HomeyTuyaScene[]>(SCENE_CACHE_KEY) ?? []).filter(scene =>
scene.name.toLowerCase().includes(query.toLowerCase()),
);

const scenes = this.apiCache.get<HomeyTuyaScene[]>(SCENE_CACHE_KEY) ?? [];

const trimmedQuery = (query ?? '').trim();
if (!trimmedQuery) {
return scenes;
}

return scenes.filter(scene => scene.name.toLowerCase().includes(trimmedQuery.toLowerCase()));
});

this.log('Tuya started');
Expand Down
2 changes: 1 addition & 1 deletion lib/TuyaOAuth2Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ export default class TuyaOAuth2Client extends OAuth2Client<TuyaOAuth2Token> {
return this._get(`/v1.0/users/${this.getToken().uid}/devices`);
}

getDevice({ deviceId }: { deviceId: string }): Promise<TuyaDeviceResponse> {
async getDevice({ deviceId }: { deviceId: string }): Promise<TuyaDeviceResponse> {
// https://developer.tuya.com/en/docs/cloud/device-management?id=K9g6rfntdz78a
return this._get(`/v1.0/devices/${deviceId}`);
}
Expand Down
78 changes: 62 additions & 16 deletions lib/TuyaOAuth2Device.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { OAuth2Device } from 'homey-oauth2app';
import type { TuyaCommand, TuyaDeviceDataPointResponse, TuyaStatusResponse, TuyaWebRTC } from '../types/TuyaApiTypes';

import type { TuyaStatus } from '../types/TuyaTypes';
import type { TuyaStatus, TuyaStatusSource } from '../types/TuyaTypes';
import TuyaOAuth2Client from './TuyaOAuth2Client';
import * as TuyaOAuth2Util from './TuyaOAuth2Util';
import * as GeneralMigrations from './migrations/GeneralMigrations';
Expand Down Expand Up @@ -68,6 +68,12 @@ export default class TuyaOAuth2Device extends OAuth2Device<TuyaOAuth2Client> {
isOtherDevice,
);

const statusSourceUpdateCodes = this.getStoreValue('status_source_update_codes');
if (Array.isArray(statusSourceUpdateCodes)) {
this.log('Restoring status source update codes: ', JSON.stringify(statusSourceUpdateCodes));
statusSourceUpdateCodes.forEach(c => this.tuyaStatusSourceUpdateCodes.add(c));
}

if (typeof TuyaOAuth2Device.SYNC_INTERVAL === 'number') {
this.__syncInterval = this.homey.setInterval(this.__sync, TuyaOAuth2Device.SYNC_INTERVAL);
}
Expand All @@ -91,12 +97,56 @@ export default class TuyaOAuth2Device extends OAuth2Device<TuyaOAuth2Client> {
/*
* Tuya
*/
async __onTuyaStatus(status: TuyaStatus, changedStatusCodes: string[] = []): Promise<void> {
private tuyaStatusSourceUpdateCodes: Set<string> = new Set();

private async __onTuyaStatus(
source: TuyaStatusSource,
status: TuyaStatus,
changedStatusCodes: string[] = [],
): Promise<void> {
// Wait at least 100ms for initialization before trying to pass the barrier again
while (this.initBarrier) {
await new Promise(resolve => this.homey.setTimeout(resolve, 100));
}

// Filter duplicated data
if (source === 'status') {
changedStatusCodes.forEach(c => {
if (this.tuyaStatusSourceUpdateCodes.has(c)) {
return;
}

this.log('Add status source update code', c);
this.tuyaStatusSourceUpdateCodes.add(c);
this.setStoreValue('status_source_update_codes', Array.from(this.tuyaStatusSourceUpdateCodes));
});
}

if (source === 'iot_core_status') {
// GH-239: As we have two data sources, certain data point updates can come in twice.
// When a code has been reported with the status event, we should no longer listen to that code
// when coming in from the iot_core_status event.
for (const changedStatusCode of changedStatusCodes) {
if (!this.tuyaStatusSourceUpdateCodes.has(changedStatusCode)) {
continue;
}

this.log('Ignoring iot_core_status code change', changedStatusCode);
delete status[changedStatusCode];
}

// Recompute changed status codes
changedStatusCodes = Object.keys(status);
}

this.__status = {
...this.__status,
...status,
};

this.log('onTuyaStatus', source, JSON.stringify(this.__status));

// Trigger the custom code cards
for (const changedStatusCode of changedStatusCodes) {
let changedStatusValue = status[changedStatusCode];

Expand Down Expand Up @@ -131,38 +181,34 @@ export default class TuyaOAuth2Device extends OAuth2Device<TuyaOAuth2Client> {
.catch(this.error);
}

await this.onTuyaStatus(this.__status, changedStatusCodes);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
async onTuyaStatus(status: TuyaStatus, _changedStatusCodes: string[]): Promise<void> {
// Wait at least 100ms for initialization before trying to pass the barrier again
while (this.initBarrier) {
await new Promise(resolve => this.homey.setTimeout(resolve, 100));
}

this.log('onTuyaStatus', JSON.stringify(status));

if (status.online === true) {
this.setAvailable().catch(this.error);
}

if (status.online === false) {
this.setUnavailable(this.homey.__('device_offline')).catch(this.error);

// Prevent further updates that would mark the device as available
return;
}

await this.onTuyaStatus(this.__status, changedStatusCodes);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars
async onTuyaStatus(_status: TuyaStatus, _changedStatusCodes: string[]): Promise<void> {
// Overload Me
}

async __sync(): Promise<void> {
private async __sync(): Promise<void> {
Promise.resolve()
.then(async () => {
this.log('Syncing...');
const { deviceId } = this.data;
const device = await this.oAuth2Client.getDevice({ deviceId });

const status = TuyaOAuth2Util.convertStatusArrayToStatusObject(device.status);
await this.__onTuyaStatus({
await this.__onTuyaStatus('sync', {
...status,
online: device.online,
});
Expand Down
48 changes: 5 additions & 43 deletions lib/webhooks/TuyaWebhookParser.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { SimpleClass } from 'homey';
import { TuyaStatusResponse } from '../../types/TuyaApiTypes';
import type { DeviceRegistration, TuyaIotCoreStatusUpdate, TuyaStatus, TuyaStatusUpdate } from '../../types/TuyaTypes';
import { convertStatusArrayToStatusObject } from '../TuyaOAuth2Util';

Expand All @@ -23,8 +22,6 @@ type IotCoreStatusEvent = {
type TuyaWebhookData = OnlineEvent | OfflineEvent | StatusEvent | IotCoreStatusEvent;

export default class TuyaWebhookParser {
private dataHistory: string[] = [];
private dataHistoryCodes: Record<string, string[]> = {};
private readonly logContext;

constructor(logContext: SimpleClass) {
Expand All @@ -40,12 +37,12 @@ export default class TuyaWebhookParser {
case 'offline':
statusUpdate = { online: false };
break;
case 'status': // Legacy status update
statusUpdate = this.filterDuplicateData(message.data.dataId, message.data.deviceStatus);
case 'status':
statusUpdate = convertStatusArrayToStatusObject(message.data.deviceStatus);

break;
case 'iot_core_status':
statusUpdate = this.filterDuplicateData(message.data.dataId, message.data.properties);
statusUpdate = convertStatusArrayToStatusObject(message.data.properties);

break;
default:
Expand All @@ -58,44 +55,9 @@ export default class TuyaWebhookParser {
return;
}

this.logContext.log('Changed status codes', changedStatusCodes);
this.logContext.log('Changed status codes', JSON.stringify(changedStatusCodes));
for (const device of devices) {
await device?.onStatus(statusUpdate, changedStatusCodes);
await device?.onStatus(message.event, statusUpdate, changedStatusCodes);
}
}

private filterDuplicateData(dataId?: string, statuses?: TuyaStatusResponse): TuyaStatus {
const statusUpdate = convertStatusArrayToStatusObject(statuses);

if (!dataId) {
return statusUpdate;
}

// Check whether we already got this data point
if (!this.dataHistory.includes(dataId)) {
// We keep a history of 50 items
if (this.dataHistory.length >= 50) {
const oldDataId = this.dataHistory.shift();
if (oldDataId) {
delete this.dataHistoryCodes[oldDataId];
}
}

// Add the data registration
this.dataHistory.push(dataId);
this.dataHistoryCodes[dataId] = [];
}

for (const key of Object.keys(statusUpdate)) {
if (this.dataHistoryCodes[dataId]?.includes(key)) {
// Already received, so skip it
delete statusUpdate[key];
continue;
}

this.dataHistoryCodes[dataId]?.push(key);
}

return statusUpdate;
}
}
3 changes: 2 additions & 1 deletion types/TuyaTypes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type TuyaOAuth2Device from '../lib/TuyaOAuth2Device';

export type TuyaStatus = Record<string, unknown>;
export type TuyaStatusSource = 'sync' | 'online' | 'offline' | 'status' | 'iot_core_status';

// Legacy status update
export type TuyaStatusUpdate<T> = {
Expand All @@ -21,7 +22,7 @@ export type TuyaIotCoreStatusUpdate<T> = {
export type DeviceRegistration = {
productId: string;
deviceId: string;
onStatus: (status: TuyaStatus, changedStatusCodes: string[]) => Promise<void>;
onStatus: (source: TuyaStatusSource, status: TuyaStatus, changedStatusCodes: string[]) => Promise<void>;
};

export type SettingsEvent<T extends { [key: string]: unknown }> = {
Expand Down

0 comments on commit ebb079a

Please sign in to comment.