+
+
+
+ {udpScanError && (
+
+
+
+ )}
+ {isLoading && scanTextId && (
+
+ )}
+ {portErrorPorts.length > 0 && (
+
+
+
+
+
+
+
+ )}
-
{errorLoading && (
@@ -87,19 +226,20 @@ class DiscoverTab extends Component {
)}
- {discoveredDevices &&
- discoveredDevices.map((device, index) => (
-
- ))}
- {!discoveredDevices || (discoveredDevices.length === 0 && )}
+ {orderedDevices.map((device, index) => (
+
+ ))}
+ {orderedDevices.length === 0 && }
diff --git a/front/src/routes/integration/all/tuya/discover-page/TuyaGithubIssueSection.jsx b/front/src/routes/integration/all/tuya/discover-page/TuyaGithubIssueSection.jsx
new file mode 100644
index 0000000000..b1b42ed864
--- /dev/null
+++ b/front/src/routes/integration/all/tuya/discover-page/TuyaGithubIssueSection.jsx
@@ -0,0 +1,378 @@
+import { Component } from 'preact';
+import cx from 'classnames';
+import { Text, MarkupText } from 'preact-i18n';
+import {
+ buildIssueTitle,
+ buildFollowUpIssueTitle,
+ buildGithubSearchUrl,
+ checkGithubIssues,
+ createGithubIssueData,
+ createEmptyGithubIssueUrl
+} from './githubIssue';
+import { getLocalPollDpsFromParams } from '../commons/deviceHelpers';
+
+const buildInitialState = () => ({
+ githubIssueChecking: false,
+ githubIssueExists: false,
+ githubIssuePayload: null,
+ githubIssuePayloadCopied: false,
+ githubIssuePayloadUrl: null,
+ githubIssueOpened: false,
+ githubIssueLatestIssueNumber: null,
+ githubIssueTargetTitle: null,
+ githubIssueSkipDuplicateCheck: false
+});
+
+class TuyaGithubIssueSection extends Component {
+ componentWillMount() {
+ this.setState(buildInitialState());
+ }
+
+ componentWillReceiveProps(nextProps) {
+ const currentDevice = this.props.device;
+ const nextDevice = nextProps.device;
+ if (!currentDevice || !nextDevice || currentDevice.external_id !== nextDevice.external_id) {
+ this.setState(buildInitialState());
+ }
+ }
+
+ startGithubIssueCreation = async ({ skipDuplicateCheck = false, followUp = false } = {}) => {
+ const {
+ githubIssueChecking,
+ githubIssueExists,
+ githubIssuePayload,
+ githubIssuePayloadUrl,
+ githubIssueOpened,
+ githubIssueLatestIssueNumber
+ } = this.state;
+ const { device, localPollStatus, localPollError, localPollValidation, localPollDps } = this.props;
+ if (!device) {
+ return;
+ }
+ if (githubIssueChecking || githubIssuePayload || githubIssuePayloadUrl || githubIssueOpened) {
+ return;
+ }
+ if (!followUp && githubIssueExists) {
+ return;
+ }
+ const persistedLocalPollDps = getLocalPollDpsFromParams(device);
+ const effectiveLocalPollDps = localPollDps || persistedLocalPollDps;
+ const baseIssueTitle = buildIssueTitle(device);
+
+ const popup = window.open('about:blank', '_blank');
+ if (popup) {
+ popup.opener = null;
+ if (!skipDuplicateCheck) {
+ popup.document.title = 'GitHub';
+ popup.document.body.innerText = 'Searching for existing issues...';
+ }
+ }
+ let latestIssueNumber = githubIssueLatestIssueNumber;
+ let shouldOpenIssue = true;
+ if (!skipDuplicateCheck) {
+ this.setState({ githubIssueChecking: true });
+ try {
+ const searchResult = await checkGithubIssues(baseIssueTitle);
+ latestIssueNumber = searchResult.latestIssueNumber;
+ if (searchResult.exists) {
+ shouldOpenIssue = false;
+ this.setState({
+ githubIssueExists: true,
+ githubIssueLatestIssueNumber: searchResult.latestIssueNumber,
+ githubIssuePayload: null,
+ githubIssuePayloadCopied: false,
+ githubIssuePayloadUrl: null,
+ githubIssueOpened: false,
+ githubIssueTargetTitle: null,
+ githubIssueSkipDuplicateCheck: false
+ });
+ }
+ } catch (error) {
+ shouldOpenIssue = true;
+ } finally {
+ this.setState({ githubIssueChecking: false });
+ }
+ }
+
+ const closePopup = () => {
+ if (popup && !popup.closed) {
+ popup.close();
+ }
+ };
+
+ if (!shouldOpenIssue) {
+ closePopup();
+ this.setState({
+ githubIssuePayload: null,
+ githubIssuePayloadCopied: false,
+ githubIssuePayloadUrl: null,
+ githubIssueOpened: false,
+ githubIssueTargetTitle: null,
+ githubIssueSkipDuplicateCheck: false
+ });
+ return;
+ }
+
+ const targetIssueTitle = followUp ? buildFollowUpIssueTitle(baseIssueTitle, latestIssueNumber) : baseIssueTitle;
+ const issueData = createGithubIssueData(
+ device,
+ localPollStatus,
+ localPollError,
+ localPollValidation,
+ effectiveLocalPollDps,
+ { title: targetIssueTitle }
+ );
+ const issueUrl = issueData.url;
+
+ if (issueData.truncated) {
+ closePopup();
+ this.setState({
+ githubIssuePayload: issueData.body,
+ githubIssuePayloadCopied: false,
+ githubIssuePayloadUrl: issueData.url,
+ githubIssueOpened: false,
+ githubIssueTargetTitle: targetIssueTitle,
+ githubIssueSkipDuplicateCheck: skipDuplicateCheck || followUp,
+ githubIssueLatestIssueNumber: latestIssueNumber
+ });
+ return;
+ }
+
+ this.setState({
+ githubIssuePayload: null,
+ githubIssuePayloadCopied: false,
+ githubIssuePayloadUrl: null,
+ githubIssueOpened: true,
+ githubIssueTargetTitle: targetIssueTitle,
+ githubIssueSkipDuplicateCheck: skipDuplicateCheck || followUp,
+ githubIssueLatestIssueNumber: latestIssueNumber
+ });
+
+ if (popup) {
+ popup.location = issueUrl;
+ return;
+ }
+ window.open(issueUrl, '_blank');
+ };
+
+ handleCreateGithubIssue = async e => {
+ if (e && e.preventDefault) {
+ e.preventDefault();
+ }
+ await this.startGithubIssueCreation({ skipDuplicateCheck: false, followUp: false });
+ };
+
+ handleCreateGithubIssueAnyway = async e => {
+ if (e && e.preventDefault) {
+ e.preventDefault();
+ }
+ await this.startGithubIssueCreation({ skipDuplicateCheck: true, followUp: true });
+ };
+
+ copyGithubIssuePayload = async e => {
+ if (e && e.preventDefault) {
+ e.preventDefault();
+ }
+ const { githubIssuePayload } = this.state;
+ if (!githubIssuePayload) {
+ return;
+ }
+ let copied = false;
+ if (typeof navigator !== 'undefined' && navigator.clipboard && navigator.clipboard.writeText) {
+ try {
+ await navigator.clipboard.writeText(githubIssuePayload);
+ copied = true;
+ } catch (error) {
+ copied = false;
+ }
+ }
+ if (!copied && this.githubIssueTextarea) {
+ try {
+ this.githubIssueTextarea.focus();
+ this.githubIssueTextarea.select();
+ this.githubIssueTextarea.setSelectionRange(0, this.githubIssueTextarea.value.length);
+ copied = document.execCommand('copy');
+ } catch (error) {
+ copied = false;
+ } finally {
+ this.githubIssueTextarea.blur();
+ }
+ }
+ this.setState({ githubIssuePayloadCopied: copied });
+ };
+
+ openEmptyGithubIssue = async e => {
+ if (e && e.preventDefault) {
+ e.preventDefault();
+ }
+ const { device } = this.props;
+ const {
+ githubIssuePayloadUrl,
+ githubIssueChecking,
+ githubIssueOpened,
+ githubIssueTargetTitle,
+ githubIssueSkipDuplicateCheck
+ } = this.state;
+ if (!device || !githubIssuePayloadUrl || githubIssueChecking || githubIssueOpened) {
+ return;
+ }
+ const issueTitle = githubIssueTargetTitle || buildIssueTitle(device);
+ const popup = window.open('about:blank', '_blank');
+ if (popup) {
+ popup.opener = null;
+ }
+ if (githubIssueSkipDuplicateCheck) {
+ if (popup && !popup.closed) {
+ popup.location.href = createEmptyGithubIssueUrl(issueTitle);
+ } else {
+ window.open(createEmptyGithubIssueUrl(issueTitle), '_blank');
+ }
+ this.setState({ githubIssueOpened: true });
+ return;
+ }
+ this.setState({ githubIssueChecking: true });
+ let shouldOpenIssue = true;
+ try {
+ const searchResult = await checkGithubIssues(issueTitle);
+ if (searchResult.exists) {
+ shouldOpenIssue = false;
+ if (popup && !popup.closed) {
+ popup.close();
+ }
+ this.setState({
+ githubIssueExists: true,
+ githubIssueLatestIssueNumber: searchResult.latestIssueNumber
+ });
+ }
+ } catch (error) {
+ shouldOpenIssue = true;
+ } finally {
+ this.setState({ githubIssueChecking: false });
+ }
+ if (!shouldOpenIssue) {
+ return;
+ }
+ if (popup && !popup.closed) {
+ popup.location.href = createEmptyGithubIssueUrl(issueTitle);
+ } else {
+ window.open(createEmptyGithubIssueUrl(issueTitle), '_blank');
+ }
+ this.setState({ githubIssueOpened: true });
+ };
+
+ render({ actionLabelId, device, withMargin = true, showInfoWhenNoIssue = true }) {
+ const {
+ githubIssueChecking,
+ githubIssueExists,
+ githubIssuePayload,
+ githubIssuePayloadCopied,
+ githubIssuePayloadUrl,
+ githubIssueOpened,
+ githubIssueLatestIssueNumber
+ } = this.state;
+ if (!device) {
+ return null;
+ }
+ const disableGithubIssueButton =
+ githubIssueChecking || githubIssueExists || githubIssueOpened || githubIssuePayloadUrl || githubIssuePayload;
+ const disableGithubIssueCreateAnywayButton = githubIssueChecking || githubIssueOpened || githubIssuePayload;
+ const shouldShowGithubIssuePayloadPanel = Boolean(githubIssuePayload || githubIssuePayloadCopied);
+ const shouldShowGithubIssuePayloadInsideExistingInfo = Boolean(
+ githubIssueExists && (githubIssuePayload || githubIssuePayloadCopied || githubIssuePayloadUrl)
+ );
+ const githubIssuesUrl = githubIssueExists ? buildGithubSearchUrl(buildIssueTitle(device)) : null;
+
+ const renderGithubIssuePayloadContent = () => (
+
+ );
+
+ const renderGithubIssueCreateAnywayButton = () => (
+
+
+
+ );
+
+ return (
+
+ {actionLabelId && (
+
+ )}
+
+ {githubIssueExists ? (
+
+
+ {shouldShowGithubIssuePayloadInsideExistingInfo ? (
+
{renderGithubIssuePayloadContent()}
+ ) : (
+ <>
+
+
+
+
{renderGithubIssueCreateAnywayButton()}
+ >
+ )}
+
+ ) : (
+ showInfoWhenNoIssue && (
+
+
+
+ )
+ )}
+
+ {shouldShowGithubIssuePayloadPanel && !githubIssueExists && !shouldShowGithubIssuePayloadInsideExistingInfo && (
+
{renderGithubIssuePayloadContent()}
+ )}
+
+ );
+ }
+}
+
+export default TuyaGithubIssueSection;
diff --git a/front/src/routes/integration/all/tuya/discover-page/githubIssue.js b/front/src/routes/integration/all/tuya/discover-page/githubIssue.js
new file mode 100644
index 0000000000..efbd78139a
--- /dev/null
+++ b/front/src/routes/integration/all/tuya/discover-page/githubIssue.js
@@ -0,0 +1,639 @@
+import get from 'get-value';
+import {
+ normalizeBoolean,
+ buildParamsMap,
+ getParamValue,
+ getLocalOverrideValue,
+ getProductIdentifier,
+ getUnknownDpsKeys,
+ getUnknownSpecificationCodes
+} from '../commons/deviceHelpers';
+
+const GITHUB_BASE_URL = 'https://github.com/GladysAssistant/Gladys/issues/new';
+const GITHUB_SEARCH_BASE_URL = 'https://github.com/GladysAssistant/Gladys/issues?q=';
+const GITHUB_SEARCH_API_URL = 'https://api.github.com/search/issues?q=';
+const GITHUB_SEARCH_CACHE_TTL_MS = 1000 * 60 * 5;
+const MAX_GITHUB_CACHE_SIZE = 100;
+const MAX_GITHUB_URL_LENGTH = 8000;
+const githubIssueCache = new Map();
+
+const maskIp = ip => {
+ if (!ip || typeof ip !== 'string') {
+ return null;
+ }
+ const parts = ip.split('.');
+ if (parts.length !== 4 || parts.some(part => part === '' || Number.isNaN(parseInt(part, 10)))) {
+ return null;
+ }
+ return `${parts[0]}.x.x.x`;
+};
+
+const sanitizeParams = params => {
+ if (!Array.isArray(params)) {
+ return [];
+ }
+ return params.map(param => {
+ if (param.name === 'LOCAL_KEY') {
+ return { ...param, value: '***' };
+ }
+ if (param.name === 'IP_ADDRESS' || param.name === 'CLOUD_IP') {
+ return { ...param, value: maskIp(param.value) };
+ }
+ return param;
+ });
+};
+
+const sanitizeIssueValue = (key, value) => {
+ if (key === 'local_key') {
+ return '***';
+ }
+ if ((key === 'ip' || key === 'cloud_ip') && typeof value === 'string') {
+ return maskIp(value);
+ }
+ if (Array.isArray(value)) {
+ return value.map(item => sanitizeIssueValue(null, item));
+ }
+ if (value && typeof value === 'object') {
+ return Object.keys(value).reduce((acc, currentKey) => {
+ acc[currentKey] = sanitizeIssueValue(currentKey, value[currentKey]);
+ return acc;
+ }, {});
+ }
+ return value;
+};
+
+const buildFallbackTuyaReport = device => ({
+ schema_version: 2,
+ cloud: {
+ assembled: {
+ specifications: (device && device.specifications) || null,
+ properties: (device && device.properties) || null,
+ thing_model: (device && device.thing_model) || null
+ },
+ raw: {
+ device_list_entry: null,
+ device_specification: null,
+ device_details: null,
+ thing_shadow_properties: null,
+ thing_model: null
+ }
+ },
+ local: {
+ scan: null
+ }
+});
+
+const getSanitizedTuyaReport = device =>
+ sanitizeIssueValue(null, device && device.tuya_report ? device.tuya_report : buildFallbackTuyaReport(device));
+
+const getDeviceId = device => {
+ if (!device) {
+ return null;
+ }
+ if (device.id) {
+ return device.id;
+ }
+ const paramDeviceId = getParamValue(device, 'DEVICE_ID');
+ if (paramDeviceId) {
+ return paramDeviceId;
+ }
+ if (device.external_id && device.external_id.includes(':')) {
+ return device.external_id.split(':')[1] || null;
+ }
+ return null;
+};
+
+const compactObject = value => {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
+ return value;
+ }
+ return Object.keys(value).reduce((acc, key) => {
+ if (value[key] !== undefined && value[key] !== null) {
+ acc[key] = value[key];
+ }
+ return acc;
+ }, {});
+};
+
+const isResolvedDeviceType = value => !!value && value !== 'unknown';
+
+const toPrettyJson = value => JSON.stringify(value, null, 2);
+
+const toCodeBlock = (language, value) =>
+ `\`\`\`${language}\n${typeof value === 'string' ? value : toPrettyJson(value)}\n\`\`\``;
+
+const slugifyFixturePart = value => {
+ if (!value || typeof value !== 'string') {
+ return 'tuya-device';
+ }
+ const slug = value
+ .normalize('NFD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ return slug || 'tuya-device';
+};
+
+const buildSuggestedFixtureDirectory = issuePayload => {
+ const modelPart = slugifyFixturePart(get(issuePayload, 'device.model') || get(issuePayload, 'device.name'));
+ const productPart = slugifyFixturePart(get(issuePayload, 'device.product_id') || get(issuePayload, 'device.id'));
+ if (modelPart === productPart) {
+ return modelPart;
+ }
+ return `${modelPart}-${productPart}`;
+};
+
+const buildSuggestedInputDevice = issuePayload => {
+ const device = issuePayload && issuePayload.device ? issuePayload.device : {};
+ const params = buildParamsMap(device);
+ const details = get(issuePayload, 'cloud.raw.device_details.response.result') || {};
+ return compactObject({
+ id: device.id || details.id || params.DEVICE_ID,
+ name: device.name || details.name,
+ product_name: details.product_name || device.model,
+ model: details.model || device.model,
+ product_id: device.product_id || details.product_id,
+ product_key: device.product_key || details.product_key || params.PRODUCT_KEY,
+ local_key: details.local_key || params.LOCAL_KEY,
+ ip: params.IP_ADDRESS || null,
+ cloud_ip: params.CLOUD_IP || details.ip,
+ protocol_version: device.protocol_version || null,
+ local_override: normalizeBoolean(device.local_override),
+ online: device.online,
+ specifications: get(issuePayload, 'cloud.assembled.specifications') || {},
+ properties: get(issuePayload, 'cloud.assembled.properties') || {},
+ thing_model: get(issuePayload, 'cloud.assembled.thing_model') || null
+ });
+};
+
+const buildSuggestedPollDevice = issuePayload =>
+ compactObject({
+ external_id: get(issuePayload, 'device.external_id') || null,
+ device_type: isResolvedDeviceType(get(issuePayload, 'device.device_type'))
+ ? get(issuePayload, 'device.device_type')
+ : null,
+ params: get(issuePayload, 'device.params') || [],
+ features: get(issuePayload, 'device.features') || [],
+ tuya_mapping: get(issuePayload, 'device.tuya_mapping') || null
+ });
+
+const buildSuggestedCloudStatus = issuePayload => {
+ const properties = get(issuePayload, 'cloud.assembled.properties.properties') || [];
+ return {
+ result: properties
+ .filter(item => item && item.code !== undefined && item.code !== null && item.value !== undefined)
+ .map(item => ({
+ code: item.code,
+ value: item.value
+ }))
+ };
+};
+
+const escapeJsString = value =>
+ String(value || '')
+ .replace(/\\/g, '\\\\')
+ .replace(/'/g, "\\'");
+
+const buildSuggestedManifest = issuePayload => {
+ const name = get(issuePayload, 'device.name') || get(issuePayload, 'device.model') || 'tuya device';
+ const hasLocalPoll = !!get(issuePayload, 'local.poll.dps');
+ const lines = [
+ 'module.exports = {',
+ ` name: '${escapeJsString(name)}',`,
+ ' convertDevice: {',
+ " input: './input-device.json',",
+ " expected: './expected-device.json',",
+ ' },',
+ ' pollCloud: {',
+ " device: './poll-device.json',",
+ " response: './cloud-status.json',",
+ " expectedEvents: './expected-cloud-events.json',",
+ ' },'
+ ];
+ if (hasLocalPoll) {
+ lines.push(
+ ' pollLocal: {',
+ " device: './poll-device.json',",
+ " dps: './local-dps.json',",
+ " expectedEvents: './expected-local-events.json',",
+ ' expectedCloudRequests: 0,',
+ ' },'
+ );
+ }
+ lines.push(
+ ' localMapping: {',
+ " device: './poll-device.json',",
+ " expected: './expected-local-mapping.json',",
+ ' },',
+ '};'
+ );
+ return lines.join('\n');
+};
+
+const buildSupplementalDiagnostics = (device, issuePayload, localPollStatus, localPollError, effectiveLocalPollDps) => {
+ const unknownSpecificationCodes = getUnknownSpecificationCodes(device.specifications, device.features, device);
+ const unknownLocalDpsKeys = getUnknownDpsKeys(effectiveLocalPollDps, device.features, device);
+ const listEntry = get(issuePayload, 'cloud.raw.device_list_entry.response_item') || {};
+ const listStatus = Array.isArray(listEntry.status) ? listEntry.status : [];
+ const assembledSpecifications = get(issuePayload, 'cloud.assembled.specifications') || {};
+ const assembledProperties = get(issuePayload, 'cloud.assembled.properties') || {};
+ const assembledThingModel = get(issuePayload, 'cloud.assembled.thing_model') || {};
+ const thingServices = Array.isArray(assembledThingModel.services) ? assembledThingModel.services : [];
+ const thingModelPropertyCount = thingServices.reduce((acc, service) => {
+ const serviceProperties = Array.isArray(service && service.properties) ? service.properties : [];
+ return acc + serviceProperties.length;
+ }, 0);
+ const cloudErrors = compactObject({
+ specification: get(issuePayload, 'cloud.raw.device_specification.error') || null,
+ details: get(issuePayload, 'cloud.raw.device_details.error') || null,
+ shadow_properties: get(issuePayload, 'cloud.raw.thing_shadow_properties.error') || null,
+ thing_model: get(issuePayload, 'cloud.raw.thing_model.error') || null
+ });
+ return compactObject({
+ selector: get(issuePayload, 'device.selector') || null,
+ service_id: get(issuePayload, 'device.service_id') || null,
+ device_type: get(issuePayload, 'device.device_type') || null,
+ feature_count: Array.isArray(get(issuePayload, 'device.features'))
+ ? get(issuePayload, 'device.features').length
+ : 0,
+ discovery_inputs: {
+ product_id: get(issuePayload, 'device.product_id') || null,
+ model: get(issuePayload, 'device.model') || null,
+ category_from_specification: assembledSpecifications.category || null,
+ category_from_list_entry: listEntry.category || null,
+ thing_model_id: assembledThingModel.modelId || null
+ },
+ cloud_source_counts: {
+ specification_functions: Array.isArray(assembledSpecifications.functions)
+ ? assembledSpecifications.functions.length
+ : 0,
+ specification_status: Array.isArray(assembledSpecifications.status) ? assembledSpecifications.status.length : 0,
+ list_status: listStatus.length,
+ shadow_properties: Array.isArray(assembledProperties.properties) ? assembledProperties.properties.length : 0,
+ thing_model_properties: thingModelPropertyCount
+ },
+ cloud_raw_errors: Object.keys(cloudErrors).length > 0 ? cloudErrors : undefined,
+ protocol_version: get(issuePayload, 'device.protocol_version') || null,
+ poll_frequency: get(issuePayload, 'device.poll_frequency') || null,
+ should_poll: get(issuePayload, 'device.should_poll') || null,
+ local_poll_status: localPollStatus || null,
+ local_poll_error: localPollError || null,
+ unknown_specification_codes: unknownSpecificationCodes.length > 0 ? unknownSpecificationCodes : undefined,
+ unknown_local_dps: unknownLocalDpsKeys.length > 0 ? unknownLocalDpsKeys : undefined
+ });
+};
+
+export const buildIssueTitle = device => {
+ const isLocal = normalizeBoolean(getLocalOverrideValue(device));
+ const modeLabel = isLocal ? 'local' : 'cloud';
+ const productIdentifier = getProductIdentifier(device);
+ const modelLabel = device.model || device.product_name || device.name || 'Unknown device';
+ return `Tuya (${modeLabel}) [${productIdentifier}]: Add support for ${modelLabel}`;
+};
+
+export const buildFollowUpIssueTitle = (baseTitle, latestIssueNumber) => {
+ const normalizedBaseTitle = baseTitle || 'Tuya: Add support for unknown device';
+ const parsedIssueNumber = Number.parseInt(latestIssueNumber, 10);
+ if (Number.isInteger(parsedIssueNumber) && parsedIssueNumber > 0) {
+ return `${normalizedBaseTitle} (follow-up of #${parsedIssueNumber})`;
+ }
+ return `${normalizedBaseTitle} (follow-up)`;
+};
+
+const buildGithubSearchQuery = title => `repo:GladysAssistant/Gladys in:title "${title}"`;
+
+export const buildGithubSearchUrl = title =>
+ `${GITHUB_SEARCH_BASE_URL}${encodeURIComponent(buildGithubSearchQuery(title))}`;
+
+const buildGithubSearchApiUrl = title => {
+ const query = buildGithubSearchQuery(title);
+ return `${GITHUB_SEARCH_API_URL}${encodeURIComponent(query)}&sort=created&order=desc&per_page=1`;
+};
+
+const setGithubIssueCache = (query, value) => {
+ if (githubIssueCache.has(query)) {
+ githubIssueCache.delete(query);
+ }
+ if (githubIssueCache.size >= MAX_GITHUB_CACHE_SIZE) {
+ const oldestKey = githubIssueCache.keys().next().value;
+ if (oldestKey !== undefined) {
+ githubIssueCache.delete(oldestKey);
+ }
+ }
+ githubIssueCache.set(query, value);
+};
+
+export const checkGithubIssues = async title => {
+ const query = buildGithubSearchQuery(title);
+ const cached = githubIssueCache.get(query);
+ if (cached && Date.now() - cached.timestamp < GITHUB_SEARCH_CACHE_TTL_MS) {
+ return cached.result;
+ }
+
+ let response;
+ const searchApiUrl = buildGithubSearchApiUrl(title);
+ if (typeof AbortController === 'undefined') {
+ response = await fetch(searchApiUrl);
+ } else {
+ const controller = new AbortController();
+ const timeoutId = setTimeout(() => controller.abort(), 5000);
+ try {
+ response = await fetch(searchApiUrl, {
+ signal: controller.signal
+ });
+ } finally {
+ clearTimeout(timeoutId);
+ }
+ }
+ if (!response.ok) {
+ throw new Error('Github search failed');
+ }
+ const data = await response.json();
+ const totalCount = data && typeof data.total_count === 'number' ? data.total_count : 0;
+ const firstIssue = Array.isArray(data && data.items) && data.items.length > 0 ? data.items[0] : null;
+ const latestIssueNumber =
+ firstIssue && typeof firstIssue.number === 'number' && firstIssue.number > 0 ? firstIssue.number : null;
+ const result = {
+ exists: totalCount > 0,
+ totalCount,
+ latestIssueNumber
+ };
+ setGithubIssueCache(query, { result, timestamp: Date.now() });
+ return result;
+};
+
+const buildIssuePayload = (device, localPollStatus, localPollError, localPollValidation, localPollDps) => {
+ if (!device) {
+ return null;
+ }
+ const productId = device.product_id || getParamValue(device, 'PRODUCT_ID') || null;
+ const productKey = device.product_key || getParamValue(device, 'PRODUCT_KEY') || null;
+ const tuyaReport = getSanitizedTuyaReport(device);
+ return {
+ schema_version: tuyaReport && tuyaReport.schema_version ? tuyaReport.schema_version : 2,
+ report_type: 'tuya-unsupported-device',
+ device: {
+ id: getDeviceId(device),
+ name: device.name || null,
+ selector: device.selector || null,
+ model: device.model || device.product_name || null,
+ external_id: device.external_id || null,
+ service_id: device.service_id || null,
+ product_id: productId,
+ product_key: productKey,
+ device_type: device.device_type || null,
+ online: device.online !== undefined ? device.online : null,
+ poll_frequency: device.poll_frequency !== undefined ? device.poll_frequency : null,
+ protocol_version: device.protocol_version || null,
+ local_override: getLocalOverrideValue(device),
+ should_poll: device.should_poll !== undefined ? device.should_poll : null,
+ features: Array.isArray(device.features) ? device.features : [],
+ params: sanitizeParams(device.params),
+ tuya_mapping: device.tuya_mapping || null
+ },
+ cloud: tuyaReport.cloud,
+ local: {
+ scan: tuyaReport.local ? tuyaReport.local.scan || null : null,
+ poll: {
+ status: localPollStatus || null,
+ error: localPollError || null,
+ protocol: localPollValidation ? localPollValidation.protocol : null,
+ dps: localPollDps || null
+ }
+ }
+ };
+};
+
+const buildIssueBody = (device, localPollStatus, localPollError, localPollValidation, localPollDps) => {
+ const issuePayload = buildIssuePayload(device, localPollStatus, localPollError, localPollValidation, localPollDps);
+ if (!issuePayload) {
+ return '';
+ }
+
+ const fixtureDirectory = buildSuggestedFixtureDirectory(issuePayload);
+ const inputDevice = buildSuggestedInputDevice(issuePayload);
+ const pollDevice = buildSuggestedPollDevice(issuePayload);
+ const cloudStatus = buildSuggestedCloudStatus(issuePayload);
+ const localDps = get(issuePayload, 'local.poll.dps') || null;
+ const localScan = get(issuePayload, 'local.scan.response') || null;
+ const supplementalDiagnostics = buildSupplementalDiagnostics(
+ device,
+ issuePayload,
+ localPollStatus,
+ localPollError,
+ localDps
+ );
+
+ const sections = [
+ '## Requester Notes',
+ '',
+ 'Please add a short description of your request below (context, expected behavior, observed behavior).',
+ '',
+ '',
+ '',
+ '## Summary',
+ '',
+ 'Unsupported or partially supported Tuya device report.',
+ '',
+ `- Device name: \`${get(issuePayload, 'device.name') || 'Unknown device'}\``,
+ `- External ID: \`${get(issuePayload, 'device.external_id') || 'unknown'}\``,
+ `- Product ID: \`${get(issuePayload, 'device.product_id') || 'unknown'}\``,
+ `- Product key: \`${get(issuePayload, 'device.product_key') || 'unknown'}\``,
+ `- Model: \`${get(issuePayload, 'device.model') || 'unknown'}\``,
+ `- Category: \`${get(issuePayload, 'cloud.assembled.specifications.category') || 'unknown'}\``,
+ `- Online: \`${String(get(issuePayload, 'device.online'))}\``,
+ `- Local override: \`${String(get(issuePayload, 'device.local_override'))}\``,
+ `- Local poll status: \`${get(issuePayload, 'local.poll.status') || 'not-run'}\``,
+ `- Local poll protocol: \`${get(issuePayload, 'local.poll.protocol') || 'unknown'}\``,
+ '',
+ '## Implementation Guide',
+ '',
+ 'Implement the Tuya support first, then finalize the tests:',
+ '',
+ '1. Add or update the Tuya cloud mapping in `server/services/tuya/lib/mappings/cloud/`.',
+ '2. Add or update the Tuya local mapping in `server/services/tuya/lib/mappings/local/`.',
+ '3. Register or update the device definition in `server/services/tuya/lib/mappings/index.js`.',
+ '4. If the new feature needs a new Gladys `category/type`, add it in `server/utils/constants.js`.',
+ '5. If the new feature uses a new `category/type` not yet handled by Tuya reading, update `readValues` in `server/services/tuya/lib/device/tuya.deviceMapping.js`.',
+ '6. If the feature should be writable from Gladys, also update `writeValues` in `server/services/tuya/lib/device/tuya.deviceMapping.js`.',
+ '',
+ '## Maintainer Notes',
+ '',
+ 'This issue contains:',
+ '- the complete structured Tuya report',
+ '- suggested fixture input files for onboarding tests',
+ '- raw cloud responses',
+ '- local poll DPS when available',
+ '',
+ 'This issue does not contain:',
+ '- `expected-device.json`',
+ '- `expected-events.json` or `expected-cloud-events.json` / `expected-local-events.json`',
+ '- `expected-local-mapping.json`',
+ '',
+ 'These expected files should still be reviewed and created manually.',
+ '',
+ '`poll-device.json` is only a starting point based on the current support level in Gladys.',
+ 'After adding or updating mappings, it should be completed with the final supported features and mapping metadata.',
+ '',
+ '## Data Coverage',
+ '',
+ '- `GET /v1.0/users/{sourceId}/devices` -> `Device List Entry` below',
+ '- `GET /v1.2/iot-03/devices/{deviceId}/specification` -> `input-device.json > specifications`',
+ '- `GET /v1.0/iot-03/devices/{deviceId}` -> `input-device.json` top-level metadata',
+ '- `GET /v2.0/cloud/thing/{deviceId}/shadow/properties` -> `input-device.json > properties` and `cloud-status.json`',
+ '- `GET /v2.0/cloud/thing/{deviceId}/model` -> `input-device.json > thing_model`',
+ '- local poll -> `local-dps.json` when available',
+ '',
+ '## Suggested Fixture Folder',
+ '',
+ toCodeBlock('text', fixtureDirectory),
+ '',
+ 'Example:',
+ '',
+ toCodeBlock('text', `server/test/services/tuya/fixtures/devices/${fixtureDirectory}`),
+ '',
+ '## Suggested File: manifest.js',
+ '',
+ toCodeBlock('js', buildSuggestedManifest(issuePayload)),
+ '',
+ '## Suggested File: input-device.json',
+ '',
+ 'Use this as the base input for `convertDevice`.',
+ '',
+ toCodeBlock('json', inputDevice),
+ '',
+ '## Suggested File: poll-device.json',
+ '',
+ 'Use this as a base Gladys device for cloud/local poll tests.',
+ '',
+ 'Important:',
+ '- this file reflects the support level available when the issue was created',
+ '- after implementing support in mappings, update `device_type`, `features`, and `tuya_mapping` to match the final Gladys support',
+ '- if the final support changes significantly, prefer rebuilding this file from the resulting converted device',
+ '',
+ toCodeBlock('json', pollDevice),
+ '',
+ '## Suggested File: cloud-status.json',
+ '',
+ 'Use this as the cloud poll response fixture.',
+ '',
+ 'Derived from `cloud.assembled.properties`.',
+ '',
+ toCodeBlock('json', cloudStatus),
+ ''
+ ];
+
+ if (localDps) {
+ sections.push(
+ '## Suggested File: local-dps.json',
+ '',
+ 'Use this as the local poll DPS fixture.',
+ '',
+ toCodeBlock('json', localDps),
+ ''
+ );
+ }
+
+ if (localScan) {
+ sections.push(
+ '## Suggested File: local-scan.json',
+ '',
+ 'Use this only if a local scan payload exists.',
+ '',
+ toCodeBlock('json', localScan),
+ ''
+ );
+ }
+
+ sections.push(
+ '## Manual Files Guide',
+ '',
+ 'Build the remaining files manually after the Tuya mappings are in place:',
+ '',
+ '1. Paste `input-device.json` from this issue, then add or update the Tuya mappings in `server/services/tuya/lib/mappings/cloud/`, `server/services/tuya/lib/mappings/local/`, and `server/services/tuya/lib/mappings/index.js`.',
+ '2. Create `expected-device.json` from the final Gladys device returned by `convertDevice`. This corresponds to the Tuya device as it should appear in the Discover page once support is implemented.',
+ '3. Build or complete `poll-device.json` from the final supported Gladys features. It should contain the final `device_type`, `features`, params, and `tuya_mapping` metadata.',
+ '4. Copy `cloud-status.json` from the cloud property values and `local-dps.json` from the local poll DPS payload when available.',
+ '5. Create `expected-events.json` if cloud and local polling should emit the same states. If they differ, keep separate `expected-cloud-events.json` and `expected-local-events.json` files instead.',
+ '6. Create `expected-local-mapping.json` from the final supported features present in `poll-device.json`, using the resolved DPS returned by the local mapping.',
+ ''
+ );
+
+ sections.push(
+ '## Device List Entry',
+ '',
+ 'Source: `GET /v1.0/users/{sourceId}/devices`.',
+ '',
+ toCodeBlock('json', get(issuePayload, 'cloud.raw.device_list_entry.response_item')),
+ '',
+ '## Supplemental Diagnostics',
+ '',
+ 'Additional metadata that does not naturally belong to the suggested fixture files.',
+ '',
+ toCodeBlock('json', supplementalDiagnostics),
+ '',
+ 'Note:',
+ '',
+ '- The raw `thing_model.response.result.model` string is intentionally omitted here because it is already represented in a readable object form in `input-device.json > thing_model`.',
+ '',
+ '## Remaining Files To Create Manually',
+ '',
+ toCodeBlock(
+ 'text',
+ [
+ 'mapping file(s) in `server/services/tuya/lib/mappings/cloud/` and/or `server/services/tuya/lib/mappings/local/`',
+ '`server/services/tuya/lib/mappings/index.js`',
+ '`server/utils/constants.js` when a new Gladys category/type is required',
+ '`server/services/tuya/lib/device/tuya.deviceMapping.js` when new read/write handlers are required',
+ 'poll-device.json (completed with final supported features)',
+ 'expected-device.json',
+ 'expected-events.json or expected-cloud-events.json / expected-local-events.json',
+ 'expected-local-mapping.json'
+ ].join('\n')
+ ),
+ '',
+ '## Validation Checklist',
+ '',
+ '- Confirm the inferred `device_type`',
+ '- Create or update the mapping files in `server/services/tuya/lib/mappings/cloud/` and/or `server/services/tuya/lib/mappings/local/`',
+ '- Register the mapping in `server/services/tuya/lib/mappings/index.js`',
+ '- Update `server/utils/constants.js` if the feature introduces a new Gladys `category/type`',
+ '- Update `server/services/tuya/lib/device/tuya.deviceMapping.js` if new `readValues` or `writeValues` handlers are needed',
+ '- Rebuild `poll-device.json` so it matches the final supported Gladys features',
+ '- Build `expected-device.json` from the final converted Gladys device shown in Discover',
+ '- Confirm the expected Gladys features',
+ '- Use a shared `expected-events.json` only when cloud and local polling should emit the same states',
+ '- Confirm cloud polling behavior',
+ '- Confirm local polling behavior',
+ '- Confirm local mapping',
+ '- Add or update mapping files if needed'
+ );
+
+ return sections.join('\n');
+};
+
+export const createGithubIssueData = (
+ device,
+ localPollStatus,
+ localPollError,
+ localPollValidation,
+ localPollDps,
+ options = {}
+) => {
+ const issueTitle = options.title || buildIssueTitle(device);
+ const title = encodeURIComponent(issueTitle);
+ const body = buildIssueBody(device, localPollStatus, localPollError, localPollValidation, localPollDps);
+ const urlWithBody = `${GITHUB_BASE_URL}?title=${title}&body=${encodeURIComponent(body)}`;
+ if (urlWithBody.length <= MAX_GITHUB_URL_LENGTH) {
+ return { url: urlWithBody, body, truncated: false, title: issueTitle };
+ }
+ return {
+ url: `${GITHUB_BASE_URL}?title=${title}`,
+ body,
+ truncated: true,
+ title: issueTitle
+ };
+};
+
+export const createEmptyGithubIssueUrl = title => `${GITHUB_BASE_URL}?title=${encodeURIComponent(title)}`;
diff --git a/front/src/routes/integration/all/tuya/discover-page/style.css b/front/src/routes/integration/all/tuya/discover-page/style.css
index 0ce9311518..b226dd9f9e 100644
--- a/front/src/routes/integration/all/tuya/discover-page/style.css
+++ b/front/src/routes/integration/all/tuya/discover-page/style.css
@@ -5,3 +5,9 @@
.tuyaListBody {
min-height: 200px;
}
+
+.scanLoader {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
diff --git a/front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx b/front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx
index b83ae99277..15524a8cf2 100644
--- a/front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx
+++ b/front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx
@@ -4,10 +4,38 @@ import cx from 'classnames';
import { RequestStatus } from '../../../../../utils/consts';
import { Component } from 'preact';
import { connect } from 'unistore/preact';
+import { WEBSOCKET_MESSAGE_TYPES } from '../../../../../../../server/utils/constants';
class SetupTab extends Component {
- componentWillMount() {
+ constructor(props) {
+ super(props);
+ this.state = {
+ tuyaConnectionStatus: null,
+ tuyaConnectionError: null,
+ tuyaConnected: false,
+ tuyaConnecting: false,
+ tuyaConfigured: false,
+ tuyaDisconnected: false,
+ tuyaManuallyDisconnected: false,
+ tuyaManualDisconnectJustDone: false,
+ tuyaJustSaved: false,
+ tuyaJustSavedMissing: false,
+ tuyaDisconnecting: false,
+ tuyaStatusLoading: false,
+ showClientSecret: false
+ };
+ }
+
+ componentDidMount() {
this.getTuyaConfiguration();
+ this.getTuyaStatus();
+ this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, this.updateConnectionStatus);
+ this.props.session.dispatcher.addListener(WEBSOCKET_MESSAGE_TYPES.TUYA.ERROR, this.displayConnectionError);
+ }
+
+ componentWillUnmount() {
+ this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS, this.updateConnectionStatus);
+ this.props.session.dispatcher.removeListener(WEBSOCKET_MESSAGE_TYPES.TUYA.ERROR, this.displayConnectionError);
}
async getTuyaConfiguration() {
@@ -15,84 +43,309 @@ class SetupTab extends Component {
let tuyaAccessKey = '';
let tuyaSecretKey = '';
let tuyaAppAccountId = '';
+ let tuyaAppUsername = '';
this.setState({
tuyaGetSettingsStatus: RequestStatus.Getting,
tuyaEndpoint,
tuyaAccessKey,
tuyaSecretKey,
- tuyaAppAccountId
+ tuyaAppAccountId,
+ tuyaAppUsername
});
- try {
- const { value: endpoint } = await this.props.httpClient.get('/api/v1/service/tuya/variable/TUYA_ENDPOINT');
- tuyaEndpoint = endpoint;
-
- const { value: accessKey } = await this.props.httpClient.get('/api/v1/service/tuya/variable/TUYA_ACCESS_KEY');
- tuyaAccessKey = accessKey;
+ const getVariable = async (name, fallback = '') => {
+ try {
+ const response = await this.props.httpClient.get(`/api/v1/service/tuya/variable/${name}`);
+ return response && response.value ? response.value : fallback;
+ } catch (e) {
+ if (e && e.response && e.response.status === 404) {
+ return fallback;
+ }
+ throw e;
+ }
+ };
- const { value: secretKey } = await this.props.httpClient.get('/api/v1/service/tuya/variable/TUYA_SECRET_KEY');
- tuyaSecretKey = secretKey;
-
- const { value: appAccountId } = await this.props.httpClient.get(
- '/api/v1/service/tuya/variable/TUYA_APP_ACCOUNT_UID'
- );
- tuyaAppAccountId = appAccountId;
+ try {
+ [tuyaEndpoint, tuyaAccessKey, tuyaSecretKey, tuyaAppAccountId, tuyaAppUsername] = await Promise.all([
+ getVariable('TUYA_ENDPOINT'),
+ getVariable('TUYA_ACCESS_KEY'),
+ getVariable('TUYA_SECRET_KEY'),
+ getVariable('TUYA_APP_ACCOUNT_UID'),
+ getVariable('TUYA_APP_USERNAME')
+ ]);
this.setState({
tuyaGetSettingsStatus: RequestStatus.Success,
tuyaEndpoint,
tuyaAccessKey,
tuyaSecretKey,
- tuyaAppAccountId
+ tuyaAppAccountId,
+ tuyaAppUsername,
+ tuyaConfigured: !!(tuyaEndpoint && tuyaAccessKey && tuyaSecretKey && tuyaAppAccountId)
+ });
+ } catch (e) {
+ this.setState({
+ tuyaGetSettingsStatus: RequestStatus.Error,
+ tuyaEndpoint,
+ tuyaAccessKey,
+ tuyaSecretKey,
+ tuyaAppAccountId,
+ tuyaAppUsername,
+ tuyaConfigured: !!(tuyaEndpoint && tuyaAccessKey && tuyaSecretKey && tuyaAppAccountId)
+ });
+ }
+ }
+
+ async getTuyaStatus() {
+ this.setState({
+ tuyaStatusLoading: true
+ });
+ try {
+ const response = await this.props.httpClient.get('/api/v1/service/tuya/status');
+ const status = response && response.status;
+ const configured = response && response.configured;
+ const manualDisconnect = response && response.manual_disconnect;
+ const isConnected = status === 'connected';
+ const isConnecting = status === 'connecting';
+ const isError = status === 'error';
+ const isManualDisconnect = !!manualDisconnect;
+ const isUnexpectedDisconnect = isError && configured && !manualDisconnect;
+
+ this.setState({
+ tuyaStatusLoading: false,
+ tuyaConfigured: !!configured,
+ tuyaConnected: isManualDisconnect ? false : isConnected,
+ tuyaConnecting: isManualDisconnect ? false : isConnecting,
+ tuyaDisconnected: isUnexpectedDisconnect,
+ tuyaManuallyDisconnected: isManualDisconnect,
+ tuyaManualDisconnectJustDone: false,
+ tuyaJustSaved: false,
+ tuyaJustSavedMissing: false,
+ tuyaConnectionStatus: isManualDisconnect ? null : isError ? RequestStatus.Error : null,
+ tuyaConnectionError: isManualDisconnect ? null : isError ? response.error : null
});
} catch (e) {
this.setState({
- tuyaGetSettingsStatus: RequestStatus.Error
+ tuyaStatusLoading: false,
+ tuyaConnectionStatus: RequestStatus.Error,
+ tuyaConnectionError:
+ (e && e.response && e.response.data && e.response.data.message) || e.message || 'Status fetch failed'
});
}
}
saveTuyaConfiguration = async e => {
e.preventDefault();
+ const tuyaEndpoint = (this.state.tuyaEndpoint || '').trim();
+ const tuyaAccessKey = (this.state.tuyaAccessKey || '').trim();
+ const tuyaSecretKey = (this.state.tuyaSecretKey || '').trim();
+ const tuyaAppAccountId = (this.state.tuyaAppAccountId || '').trim();
+ const tuyaAppUsername = (this.state.tuyaAppUsername || '').trim();
+
this.setState({
- tuyaSaveSettingsStatus: RequestStatus.Getting
+ tuyaSaveSettingsStatus: RequestStatus.Getting,
+ tuyaConnectionStatus: null,
+ tuyaConnectionError: null,
+ tuyaConnected: false,
+ tuyaConnecting: false,
+ tuyaDisconnected: false,
+ tuyaManuallyDisconnected: false,
+ tuyaManualDisconnectJustDone: false,
+ tuyaJustSaved: true,
+ tuyaJustSavedMissing: false
});
try {
await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_ENDPOINT', {
- value: this.state.tuyaEndpoint
+ value: tuyaEndpoint
});
await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_ACCESS_KEY', {
- value: this.state.tuyaAccessKey.trim()
+ value: tuyaAccessKey
});
await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_SECRET_KEY', {
- value: this.state.tuyaSecretKey.trim()
+ value: tuyaSecretKey
});
await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_APP_ACCOUNT_UID', {
- value: this.state.tuyaAppAccountId.trim()
+ value: tuyaAppAccountId
});
+ await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_APP_USERNAME', {
+ value: tuyaAppUsername
+ });
+
+ await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_LAST_CONNECTED_CONFIG_HASH', {
+ value: ''
+ });
+
+ await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_MANUAL_DISCONNECT', {
+ value: 'false'
+ });
+
+ const configured = !!(tuyaEndpoint && tuyaAccessKey && tuyaSecretKey && tuyaAppAccountId);
+ if (!configured) {
+ this.setState({
+ tuyaSaveSettingsStatus: RequestStatus.Success,
+ tuyaConfigured: false,
+ tuyaDisconnected: true,
+ tuyaJustSavedMissing: true,
+ tuyaJustSaved: false
+ });
+ return;
+ }
+
// start service
- await this.props.httpClient.post('/api/v1/service/tuya/start');
+ const service = await this.props.httpClient.post('/api/v1/service/tuya/start');
+ if (service && service.status === 'ERROR') {
+ throw new Error('TUYA_START_ERROR');
+ }
this.setState({
- tuyaSaveSettingsStatus: RequestStatus.Success
+ tuyaSaveSettingsStatus: RequestStatus.Success,
+ tuyaConfigured: true
});
} catch (e) {
+ const responseMessage =
+ (e && e.response && e.response.data && e.response.data.message) ||
+ (e && e.message && e.message !== 'TUYA_START_ERROR' ? e.message : null);
this.setState({
- tuyaSaveSettingsStatus: RequestStatus.Error
+ tuyaSaveSettingsStatus: RequestStatus.Error,
+ tuyaConnectionError: responseMessage,
+ tuyaJustSaved: false,
+ tuyaJustSavedMissing: false
});
}
};
+ updateConnectionStatus = event => {
+ const status = event && event.status;
+ const error = event && event.error;
+ const manualDisconnect = event && event.manual_disconnect;
+ if (status === 'connecting') {
+ this.setState({
+ tuyaConnectionStatus: RequestStatus.Success,
+ tuyaConnecting: true,
+ tuyaConnected: false,
+ tuyaDisconnected: false,
+ tuyaManuallyDisconnected: false,
+ tuyaManualDisconnectJustDone: false,
+ tuyaJustSavedMissing: false
+ });
+ return;
+ }
+ if (status === 'connected') {
+ this.setState({
+ tuyaConnectionStatus: RequestStatus.Success,
+ tuyaConnectionError: null,
+ tuyaConnecting: false,
+ tuyaConnected: true,
+ tuyaDisconnected: false,
+ tuyaManuallyDisconnected: false,
+ tuyaManualDisconnectJustDone: false,
+ tuyaJustSavedMissing: false
+ });
+ return;
+ }
+ if (status === 'error') {
+ this.setState(previousState => ({
+ tuyaConnectionStatus: RequestStatus.Error,
+ tuyaConnecting: false,
+ tuyaConnected: false,
+ tuyaDisconnected: previousState.tuyaConfigured && !manualDisconnect,
+ tuyaManuallyDisconnected: false,
+ tuyaManualDisconnectJustDone: false,
+ tuyaJustSavedMissing: false,
+ tuyaConnectionError: error || previousState.tuyaConnectionError
+ }));
+ return;
+ }
+ if (status === 'not_initialized') {
+ this.setState(previousState => ({
+ tuyaConnectionStatus: null,
+ tuyaConnecting: false,
+ tuyaConnected: false,
+ tuyaDisconnected: !manualDisconnect,
+ tuyaManuallyDisconnected: !!manualDisconnect,
+ tuyaManualDisconnectJustDone: manualDisconnect ? previousState.tuyaManualDisconnectJustDone : false,
+ tuyaJustSavedMissing: false
+ }));
+ }
+ };
+
+ displayConnectionError = error => {
+ const message = (error && error.message) || (error && error.payload && error.payload.message);
+ this.setState({
+ tuyaConnectionStatus: RequestStatus.Error,
+ tuyaConnectionError: message || 'unknown',
+ tuyaConnecting: false,
+ tuyaConnected: false
+ });
+ };
+
+ renderTuyaError = error => {
+ if (!error) {
+ return null;
+ }
+ if (typeof error === 'string' && error.startsWith('integration.tuya.setup.')) {
+ return
;
+ }
+ return
{error};
+ };
+
updateConfiguration = e => {
+ const { name, value } = e.target;
+ this.setState(prevState => {
+ const nextState = { ...prevState, [name]: value };
+ const tuyaEndpoint = (nextState.tuyaEndpoint || '').trim();
+ const tuyaAccessKey = (nextState.tuyaAccessKey || '').trim();
+ const tuyaSecretKey = (nextState.tuyaSecretKey || '').trim();
+ const tuyaAppAccountId = (nextState.tuyaAppAccountId || '').trim();
+ const configured = !!(tuyaEndpoint && tuyaAccessKey && tuyaSecretKey && tuyaAppAccountId);
+ return {
+ [name]: value,
+ tuyaConfigured: configured
+ };
+ });
+ };
+
+ toggleClientSecret = () => {
+ this.setState(previousState => ({
+ showClientSecret: !previousState.showClientSecret
+ }));
+ };
+
+ disconnectFromCloud = async () => {
this.setState({
- [e.target.name]: e.target.value
+ tuyaDisconnecting: true,
+ tuyaConnectionError: null
});
+ try {
+ await this.props.httpClient.post('/api/v1/service/tuya/disconnect');
+ this.setState({
+ tuyaDisconnecting: false,
+ tuyaConnected: false,
+ tuyaConnecting: false,
+ tuyaDisconnected: false,
+ tuyaManuallyDisconnected: true,
+ tuyaManualDisconnectJustDone: true,
+ tuyaConnectionStatus: null
+ });
+ } catch (e) {
+ const responseMessage =
+ (e && e.response && e.response.data && e.response.data.message) || (e && e.message) || 'unknown';
+ this.setState({
+ tuyaDisconnecting: false,
+ tuyaConnectionStatus: RequestStatus.Error,
+ tuyaConnectionError: responseMessage
+ });
+ }
};
render(props, state) {
+ const showUnexpectedDisconnect = state.tuyaDisconnected && state.tuyaConfigured;
+ const showConnectionError = state.tuyaConnectionStatus === RequestStatus.Error;
+ const showCombinedDisconnectError = showUnexpectedDisconnect && showConnectionError;
+
return (
@@ -213,4 +580,4 @@ class SetupTab extends Component {
}
}
-export default connect('httpClient', {})(SetupTab);
+export default connect('httpClient,session', {})(SetupTab);
diff --git a/front/src/utils/consts.js b/front/src/utils/consts.js
index c1c86b355f..d7a75bec2c 100644
--- a/front/src/utils/consts.js
+++ b/front/src/utils/consts.js
@@ -294,6 +294,7 @@ export const DeviceFeatureCategoriesIcon = {
[DEVICE_FEATURE_TYPES.ENERGY_SENSOR.BINARY]: 'power',
[DEVICE_FEATURE_TYPES.ENERGY_SENSOR.POWER]: 'zap',
[DEVICE_FEATURE_TYPES.ENERGY_SENSOR.ENERGY]: 'zap',
+ [DEVICE_FEATURE_TYPES.ENERGY_SENSOR.EXPORT_INDEX]: 'zap',
[DEVICE_FEATURE_TYPES.ENERGY_SENSOR.CURRENT]: 'zap',
[DEVICE_FEATURE_TYPES.ENERGY_SENSOR.VOLTAGE]: 'zap',
[DEVICE_FEATURE_TYPES.ENERGY_SENSOR.INDEX]: 'zap',
diff --git a/server/services/tuya/api/tuya.controller.js b/server/services/tuya/api/tuya.controller.js
index 439fd52ac4..397fb77e11 100644
--- a/server/services/tuya/api/tuya.controller.js
+++ b/server/services/tuya/api/tuya.controller.js
@@ -1,4 +1,7 @@
const asyncMiddleware = require('../../../api/middlewares/asyncMiddleware');
+const logger = require('../../../utils/logger');
+const { updateDiscoveredDeviceAfterLocalPoll } = require('../lib/tuya.localPoll');
+const { buildLocalScanResponse } = require('../lib/tuya.localScan');
module.exports = function TuyaController(tuyaManager) {
/**
@@ -11,10 +14,84 @@ module.exports = function TuyaController(tuyaManager) {
res.json(devices);
}
+ /**
+ * @api {post} /api/v1/service/tuya/local-poll Poll one Tuya device locally to retrieve DPS.
+ * @apiName localPoll
+ * @apiGroup Tuya
+ */
+ async function localPoll(req, res) {
+ const payload = req.body || {};
+ const result = await tuyaManager.localPoll(payload);
+ const updatedDevice = updateDiscoveredDeviceAfterLocalPoll(tuyaManager, {
+ ...payload,
+ dps: result.dps,
+ });
+
+ if (updatedDevice) {
+ res.json({
+ ...result,
+ device: updatedDevice,
+ });
+ return;
+ }
+
+ res.json(result);
+ }
+
+ /**
+ * @api {post} /api/v1/service/tuya/local-scan Manual UDP scan for local Tuya devices.
+ * @apiName localScan
+ * @apiGroup Tuya
+ */
+ async function localScan(req, res) {
+ const { timeoutSeconds } = req.body || {};
+ logger.info(`[Tuya][localScan] API request received (timeoutSeconds=${timeoutSeconds || 10})`);
+ const localScanResult = await tuyaManager.localScan({
+ timeoutSeconds,
+ });
+ res.json(buildLocalScanResponse(tuyaManager, localScanResult));
+ }
+
+ /**
+ * @api {get} /api/v1/service/tuya/status Get Tuya connection status.
+ * @apiName status
+ * @apiGroup Tuya
+ */
+ async function status(req, res) {
+ const response = await tuyaManager.getStatus();
+ res.json(response);
+ }
+
+ /**
+ * @api {post} /api/v1/service/tuya/disconnect Disconnect Tuya cloud.
+ * @apiName disconnect
+ * @apiGroup Tuya
+ */
+ async function disconnect(req, res) {
+ await tuyaManager.manualDisconnect();
+ res.json({ success: true });
+ }
+
return {
'get /api/v1/service/tuya/discover': {
authenticated: true,
controller: asyncMiddleware(discover),
},
+ 'post /api/v1/service/tuya/local-poll': {
+ authenticated: true,
+ controller: asyncMiddleware(localPoll),
+ },
+ 'post /api/v1/service/tuya/local-scan': {
+ authenticated: true,
+ controller: asyncMiddleware(localScan),
+ },
+ 'get /api/v1/service/tuya/status': {
+ authenticated: true,
+ controller: asyncMiddleware(status),
+ },
+ 'post /api/v1/service/tuya/disconnect': {
+ authenticated: true,
+ controller: asyncMiddleware(disconnect),
+ },
};
};
diff --git a/server/services/tuya/index.js b/server/services/tuya/index.js
index 712f159d3b..de3d3397a3 100644
--- a/server/services/tuya/index.js
+++ b/server/services/tuya/index.js
@@ -6,6 +6,98 @@ const { STATUS } = require('./lib/utils/tuya.constants');
module.exports = function TuyaService(gladys, serviceId) {
const tuyaHandler = new TuyaHandler(gladys, serviceId);
+ const RECONNECT_INTERVAL_MS = 1000 * 60 * 30;
+ const QUICK_RECONNECT_ATTEMPTS = 3;
+ const QUICK_RECONNECT_DELAY_MS = 1000 * 3;
+ let reconnectInterval = null;
+ let quickReconnectTimeouts = [];
+ let quickReconnectInProgress = false;
+
+ /**
+ * @description Attempt to reconnect to Tuya if configured and not manually disconnected.
+ * @returns {Promise
} Returns true if reconnect should be retried, false otherwise.
+ * @example
+ * await tryReconnect();
+ */
+ async function tryReconnect() {
+ try {
+ if (!tuyaHandler.autoReconnectAllowed) {
+ return false;
+ }
+ const status = await tuyaHandler.getStatus();
+ if (!status.configured || status.manual_disconnect) {
+ return false;
+ }
+ if (
+ tuyaHandler.status === STATUS.CONNECTED ||
+ tuyaHandler.status === STATUS.CONNECTING ||
+ tuyaHandler.status === STATUS.DISCOVERING_DEVICES
+ ) {
+ return false;
+ }
+ logger.info('Tuya is disconnected, attempting auto-reconnect...');
+ const configuration = await tuyaHandler.getConfiguration();
+ await tuyaHandler.connect(configuration);
+ return tuyaHandler.status !== STATUS.CONNECTED;
+ } catch (e) {
+ logger.warn('Auto-reconnect to Tuya failed:', e.message || e);
+ return true;
+ }
+ }
+
+ /**
+ * @description Clear pending quick reconnect timers and reset state.
+ * @example
+ * clearQuickReconnects();
+ */
+ function clearQuickReconnects() {
+ if (quickReconnectTimeouts.length > 0) {
+ quickReconnectTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
+ quickReconnectTimeouts = [];
+ }
+ quickReconnectInProgress = false;
+ }
+
+ /**
+ * @description Schedule quick reconnect attempts when disconnected.
+ * @returns {Promise} Resolves once the current attempt is finished.
+ * @example
+ * await scheduleQuickReconnects();
+ */
+ function scheduleQuickReconnects() {
+ if (quickReconnectInProgress) {
+ return Promise.resolve();
+ }
+ quickReconnectInProgress = true;
+ let attempts = 0;
+
+ const runAttempt = async () => {
+ attempts += 1;
+ const shouldRetry = await tryReconnect();
+ const isConnecting =
+ tuyaHandler.status === STATUS.CONNECTED ||
+ tuyaHandler.status === STATUS.CONNECTING ||
+ tuyaHandler.status === STATUS.DISCOVERING_DEVICES;
+
+ if (!shouldRetry || isConnecting) {
+ clearQuickReconnects();
+ return;
+ }
+
+ if (attempts < QUICK_RECONNECT_ATTEMPTS) {
+ const timeoutId = setTimeout(runAttempt, QUICK_RECONNECT_DELAY_MS);
+ if (timeoutId && typeof timeoutId.unref === 'function') {
+ timeoutId.unref();
+ }
+ quickReconnectTimeouts.push(timeoutId);
+ return;
+ }
+
+ quickReconnectInProgress = false;
+ };
+
+ return runAttempt();
+ }
/**
* @public
@@ -16,7 +108,15 @@ module.exports = function TuyaService(gladys, serviceId) {
async function start() {
logger.info('Starting Tuya service', serviceId);
await tuyaHandler.init();
- await tuyaHandler.loadDevices();
+ if (tuyaHandler.status === STATUS.CONNECTED) {
+ await tuyaHandler.loadDevices();
+ }
+ if (tuyaHandler.status !== STATUS.CONNECTED && tuyaHandler.autoReconnectAllowed) {
+ scheduleQuickReconnects();
+ }
+ if (!reconnectInterval) {
+ reconnectInterval = setInterval(scheduleQuickReconnects, RECONNECT_INTERVAL_MS);
+ }
}
/**
@@ -27,6 +127,11 @@ module.exports = function TuyaService(gladys, serviceId) {
*/
async function stop() {
logger.info('Stopping Tuya service');
+ if (reconnectInterval) {
+ clearInterval(reconnectInterval);
+ reconnectInterval = null;
+ }
+ clearQuickReconnects();
await tuyaHandler.disconnect();
}
diff --git a/server/services/tuya/lib/device/tuya.convertDevice.js b/server/services/tuya/lib/device/tuya.convertDevice.js
index e0ca57440a..25dfd031af 100644
--- a/server/services/tuya/lib/device/tuya.convertDevice.js
+++ b/server/services/tuya/lib/device/tuya.convertDevice.js
@@ -1,43 +1,264 @@
const { DEVICE_POLL_FREQUENCIES } = require('../../../../utils/constants');
+const { DEVICE_PARAM_NAME } = require('../utils/tuya.constants');
+const { normalizeBoolean } = require('../utils/tuya.normalize');
+const { resolveCloudReadStrategy } = require('../utils/tuya.cloudStrategy');
+const { mergeTuyaReport } = require('../utils/tuya.report');
const { convertFeature } = require('./tuya.convertFeature');
+const { getDeviceType, getIgnoredCloudCodes, getIgnoredLocalDps, DEVICE_TYPES } = require('../mappings');
const logger = require('../../../../utils/logger');
+const parseFeatureValues = (values) => {
+ if (!values || typeof values !== 'object') {
+ if (typeof values === 'string') {
+ try {
+ const parsed = JSON.parse(values);
+ return parsed && typeof parsed === 'object' ? parsed : null;
+ } catch (e) {
+ return null;
+ }
+ }
+ return null;
+ }
+ return values;
+};
+
+const mergeFeatureValues = (currentValues, nextValues) => {
+ const currentParsed = parseFeatureValues(currentValues);
+ const nextParsed = parseFeatureValues(nextValues);
+
+ if (currentParsed && nextParsed) {
+ // Keep existing keys first, enrich missing metadata from the new source.
+ return {
+ ...nextParsed,
+ ...currentParsed,
+ };
+ }
+ if (currentParsed) {
+ return currentParsed;
+ }
+ if (nextParsed) {
+ return nextParsed;
+ }
+ if (currentValues !== undefined && currentValues !== null) {
+ return currentValues;
+ }
+ if (nextValues !== undefined && nextValues !== null) {
+ return nextValues;
+ }
+ return {};
+};
+
/**
* @description Transform Tuya device to Gladys device.
* @param {object} tuyaDevice - Tuya device.
- * @returns {object} Glladys device.
+ * @returns {object} Gladys device.
* @example
* tuya.convertDevice({ ... });
*/
function convertDevice(tuyaDevice) {
- const { name, product_name: model, id, specifications = {} } = tuyaDevice;
+ const {
+ name,
+ product_name: productName,
+ model,
+ product_id: productId,
+ product_key: productKey,
+ id,
+ local_key: localKey,
+ ip,
+ cloud_ip: cloudIp,
+ protocol_version: protocolVersion,
+ local_override: localOverride,
+ properties,
+ thing_model: thingModel,
+ specifications = {},
+ status: deviceStatus,
+ category,
+ tuya_report: tuyaReport,
+ } = tuyaDevice;
const externalId = `tuya:${id}`;
const { functions = [], status = [] } = specifications;
+ const online = tuyaDevice.online !== undefined ? tuyaDevice.online : tuyaDevice.is_online;
+ const normalizedLocalOverride = normalizeBoolean(localOverride);
+
+ logger.debug(`Tuya convert device "${name}, ${productName || model}"`);
+ const deviceType = getDeviceType({
+ specifications,
+ status: deviceStatus,
+ model,
+ product_name: productName,
+ product_id: productId,
+ name,
+ category: specifications.category || category,
+ properties,
+ thing_model: thingModel,
+ });
+ const cloudReadStrategy = resolveCloudReadStrategy(tuyaDevice, deviceType);
+
+ const params = [];
+ if (id) {
+ params.push({ name: DEVICE_PARAM_NAME.DEVICE_ID, value: id });
+ }
+ if (localKey) {
+ params.push({ name: DEVICE_PARAM_NAME.LOCAL_KEY, value: localKey });
+ }
+ if (ip) {
+ params.push({ name: DEVICE_PARAM_NAME.IP_ADDRESS, value: ip });
+ }
+ if (cloudIp) {
+ params.push({ name: DEVICE_PARAM_NAME.CLOUD_IP, value: cloudIp });
+ }
+ if (localOverride !== undefined && localOverride !== null) {
+ params.push({ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: normalizedLocalOverride });
+ }
+ if (protocolVersion) {
+ params.push({ name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: protocolVersion });
+ }
+ if (productId) {
+ params.push({ name: DEVICE_PARAM_NAME.PRODUCT_ID, value: productId });
+ }
+ if (productKey) {
+ params.push({ name: DEVICE_PARAM_NAME.PRODUCT_KEY, value: productKey });
+ }
+ if (cloudReadStrategy) {
+ params.push({ name: DEVICE_PARAM_NAME.CLOUD_READ_STRATEGY, value: cloudReadStrategy });
+ }
+ const safeDeviceLog = {
+ id,
+ name,
+ model: productName || model,
+ product_id: productId,
+ protocol_version: protocolVersion,
+ local_override: normalizedLocalOverride,
+ online,
+ };
+ logger.debug('Tuya convert device specifications');
+ logger.debug(JSON.stringify(safeDeviceLog));
- logger.debug(`Tuya convert device"${name}, ${model}"`);
- // Groups functions and status on same code
+ // Build features from specifications first, enrich metadata from thing model, then fallback to status/properties.
const groups = {};
status.forEach((stat) => {
- const { code } = stat;
- groups[code] = { ...stat, readOnly: true };
+ const { code } = stat || {};
+ if (!code) {
+ return;
+ }
+ const existingGroup = groups[code] || {};
+ groups[code] = {
+ ...existingGroup,
+ ...stat,
+ values: mergeFeatureValues(existingGroup.values, stat && stat.values),
+ readOnly: true,
+ };
});
functions.forEach((func) => {
- const { code } = func;
- groups[code] = { ...func, readOnly: false };
+ const { code } = func || {};
+ if (!code) {
+ return;
+ }
+ const existingGroup = groups[code] || {};
+ groups[code] = {
+ ...existingGroup,
+ ...func,
+ values: mergeFeatureValues(existingGroup.values, func && func.values),
+ readOnly: false,
+ };
+ });
+ const services = Array.isArray(thingModel && thingModel.services) ? thingModel.services : [];
+ services.forEach((service) => {
+ const thingProperties = Array.isArray(service && service.properties) ? service.properties : [];
+ thingProperties.forEach((property) => {
+ const { code } = property || {};
+ if (!code) {
+ return;
+ }
+ const existingGroup = groups[code] || {};
+ groups[code] = {
+ ...existingGroup,
+ code,
+ name: existingGroup.name || property.name,
+ values: mergeFeatureValues(existingGroup.values, property.typeSpec || {}),
+ readOnly:
+ existingGroup.readOnly !== undefined && existingGroup.readOnly !== null
+ ? existingGroup.readOnly
+ : property.accessMode !== 'rw',
+ };
+ });
+ });
+ const topLevelStatus = Array.isArray(deviceStatus) ? deviceStatus : [];
+ topLevelStatus.forEach((entry) => {
+ const { code } = entry || {};
+ if (!code || groups[code]) {
+ return;
+ }
+ groups[code] = {
+ code,
+ name: code,
+ values: {},
+ readOnly: true,
+ };
+ });
+ const currentProperties = Array.isArray(properties && properties.properties) ? properties.properties : [];
+ currentProperties.forEach((property) => {
+ const { code } = property || {};
+ if (!code || groups[code]) {
+ return;
+ }
+ groups[code] = {
+ code,
+ name: property.custom_name || property.name || code,
+ values: {},
+ readOnly: true,
+ };
});
- const features = Object.values(groups).map((group) => convertFeature(group, externalId));
+ const ignoredCloudCodes = getIgnoredCloudCodes(deviceType);
+ const ignoredLocalDps = getIgnoredLocalDps(deviceType);
+ const features = Object.values(groups).map((group) =>
+ convertFeature(group, externalId, {
+ deviceType,
+ ignoredCloudCodes,
+ }),
+ );
+ const filteredFeatures = features.filter((feature) => feature);
+ if (filteredFeatures.length === 0 && deviceType !== DEVICE_TYPES.UNKNOWN) {
+ logger.debug(
+ `[Tuya][convertDevice] inferred type=${deviceType} but no supported feature found (device=${id ||
+ 'unknown'} product_id=${productId || 'unknown'} spec_functions=${functions.length} spec_status=${
+ status.length
+ } list_status=${topLevelStatus.length} shadow_properties=${currentProperties.length} thing_services=${
+ services.length
+ })`,
+ );
+ }
const device = {
name,
- features: features.filter((feature) => feature),
+ features: filteredFeatures,
+ device_type: deviceType,
external_id: externalId,
selector: externalId,
- model,
+ model: productName || model,
+ product_id: productId,
+ product_key: productKey,
service_id: this.serviceId,
- poll_frequency: DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS,
+ poll_frequency: normalizedLocalOverride
+ ? DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS
+ : DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS,
should_poll: true,
+ params,
+ properties,
+ specifications,
+ tuya_mapping: {
+ ignored_local_dps: ignoredLocalDps,
+ ignored_cloud_codes: ignoredCloudCodes,
+ },
+ thing_model: thingModel,
};
+ if (online !== undefined) {
+ device.online = online;
+ }
+ if (tuyaReport) {
+ device.tuya_report = mergeTuyaReport(null, tuyaReport);
+ }
return device;
}
diff --git a/server/services/tuya/lib/device/tuya.convertFeature.js b/server/services/tuya/lib/device/tuya.convertFeature.js
index c882a5653e..769258ea13 100644
--- a/server/services/tuya/lib/device/tuya.convertFeature.js
+++ b/server/services/tuya/lib/device/tuya.convertFeature.js
@@ -1,34 +1,46 @@
const logger = require('../../../../utils/logger');
-const { mappings } = require('./tuya.deviceMapping');
+const { getFeatureMapping, getIgnoredCloudCodes, normalizeCode } = require('../mappings');
/**
* @description Transforms Tuya feature as Gladys feature.
* @param {object} tuyaFunctions - Functions from Tuya.
* @param {string} externalId - Gladys external ID.
+ * @param {object} options - Mapping options.
* @returns {object} Gladys feature or undefined.
* @example
* convertFeature({ code: 'switch', type: 'Boolean', values: '{}' }, 'tuya:device_id');
*/
-function convertFeature(tuyaFunctions, externalId) {
+function convertFeature(tuyaFunctions, externalId, options = {}) {
const { code, values, name, readOnly } = tuyaFunctions;
+ const { deviceType, ignoredCloudCodes } = options;
- const featuresCategoryAndType = mappings[code];
+ const codeLower = normalizeCode(code);
+ const ignoredCodes = Array.isArray(ignoredCloudCodes) ? ignoredCloudCodes : getIgnoredCloudCodes(deviceType);
+ if (codeLower && ignoredCodes.includes(codeLower)) {
+ return undefined;
+ }
+
+ const featuresCategoryAndType = getFeatureMapping(code, deviceType);
if (!featuresCategoryAndType) {
logger.warn(`Tuya function with "${code}" code is not managed`);
return undefined;
}
let valuesObject = {};
- try {
- valuesObject = JSON.parse(values);
- } catch (e) {
- logger.error(
- `Tuya function as unmappable "${values}" values on "${featuresCategoryAndType.category}/${featuresCategoryAndType.type}" type with "${code}" code`,
- );
+ if (values && typeof values === 'object') {
+ valuesObject = values;
+ } else if (typeof values === 'string') {
+ try {
+ valuesObject = JSON.parse(values);
+ } catch (e) {
+ logger.error(
+ `Tuya function as unmappable "${values}" values on "${featuresCategoryAndType.category}/${featuresCategoryAndType.type}" type with "${code}" code`,
+ );
+ }
}
const feature = {
- name,
+ name: code || name,
external_id: `${externalId}:${code}`,
selector: `${externalId}:${code}`,
read_only: readOnly,
@@ -43,6 +55,9 @@ function convertFeature(tuyaFunctions, externalId) {
if ('max' in valuesObject) {
feature.max = valuesObject.max;
}
+ if ('scale' in valuesObject) {
+ feature.scale = valuesObject.scale;
+ }
return feature;
}
diff --git a/server/services/tuya/lib/device/tuya.deviceMapping.js b/server/services/tuya/lib/device/tuya.deviceMapping.js
index 09ea472700..3ae1ca0c25 100644
--- a/server/services/tuya/lib/device/tuya.deviceMapping.js
+++ b/server/services/tuya/lib/device/tuya.deviceMapping.js
@@ -1,102 +1,28 @@
-const {
- DEVICE_FEATURE_TYPES,
- DEVICE_FEATURE_CATEGORIES,
- DEVICE_FEATURE_UNITS,
- COVER_STATE,
-} = require('../../../../utils/constants');
+const { DEVICE_FEATURE_TYPES, DEVICE_FEATURE_CATEGORIES, COVER_STATE } = require('../../../../utils/constants');
const { intToRgb, rgbToHsb, rgbToInt, hsbToRgb } = require('../../../../utils/colors');
-
-const SWITCH_LED = 'switch_led';
-const BRIGHT_VALUE_V2 = 'bright_value_v2';
-const TEMP_VALUE_V2 = 'temp_value_v2';
-const COLOUR_DATA_V2 = 'colour_data_v2';
-
-const COLOUR_DATA = 'colour_data';
-
-const ADD_ELE = 'add_ele';
-const CUR_CURRENT = 'cur_current';
-const CUR_POWER = 'cur_power';
-const CUR_VOLTAGE = 'cur_voltage';
-
-const SWITCH_1 = 'switch_1';
-const SWITCH_2 = 'switch_2';
-const SWITCH_3 = 'switch_3';
-const SWITCH_4 = 'switch_4';
-
-const CONTROL = 'control';
-const PERCENT_CONTROL = 'percent_control';
+const { normalizeBoolean } = require('../utils/tuya.normalize');
const OPEN = 'open';
const CLOSE = 'close';
const STOP = 'stop';
-const mappings = {
- [SWITCH_LED]: {
- category: DEVICE_FEATURE_CATEGORIES.LIGHT,
- type: DEVICE_FEATURE_TYPES.LIGHT.BINARY,
- },
- [BRIGHT_VALUE_V2]: {
- category: DEVICE_FEATURE_CATEGORIES.LIGHT,
- type: DEVICE_FEATURE_TYPES.LIGHT.BRIGHTNESS,
- },
- [TEMP_VALUE_V2]: {
- category: DEVICE_FEATURE_CATEGORIES.LIGHT,
- type: DEVICE_FEATURE_TYPES.LIGHT.TEMPERATURE,
- },
- [COLOUR_DATA_V2]: {
- category: DEVICE_FEATURE_CATEGORIES.LIGHT,
- type: DEVICE_FEATURE_TYPES.LIGHT.COLOR,
- },
- [COLOUR_DATA]: {
- category: DEVICE_FEATURE_CATEGORIES.LIGHT,
- type: DEVICE_FEATURE_TYPES.LIGHT.COLOR,
- },
+const getScale = (deviceFeature, defaultScale = 0) => {
+ const parsedScale =
+ deviceFeature && deviceFeature.scale !== undefined && deviceFeature.scale !== null
+ ? parseInt(deviceFeature.scale, 10)
+ : defaultScale;
- [SWITCH_1]: {
- category: DEVICE_FEATURE_CATEGORIES.SWITCH,
- type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
- },
- [SWITCH_2]: {
- category: DEVICE_FEATURE_CATEGORIES.SWITCH,
- type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
- },
- [SWITCH_3]: {
- category: DEVICE_FEATURE_CATEGORIES.SWITCH,
- type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
- },
- [SWITCH_4]: {
- category: DEVICE_FEATURE_CATEGORIES.SWITCH,
- type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
- },
- [CONTROL]: {
- category: DEVICE_FEATURE_CATEGORIES.CURTAIN,
- type: DEVICE_FEATURE_TYPES.CURTAIN.STATE,
- },
- [PERCENT_CONTROL]: {
- category: DEVICE_FEATURE_CATEGORIES.CURTAIN,
- type: DEVICE_FEATURE_TYPES.CURTAIN.POSITION,
- },
- [ADD_ELE]: {
- category: DEVICE_FEATURE_CATEGORIES.SWITCH,
- type: DEVICE_FEATURE_TYPES.SWITCH.ENERGY,
- unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR,
- },
- [CUR_CURRENT]: {
- category: DEVICE_FEATURE_CATEGORIES.SWITCH,
- type: DEVICE_FEATURE_TYPES.SWITCH.CURRENT,
- unit: DEVICE_FEATURE_UNITS.MILLI_AMPERE,
- },
- [CUR_POWER]: {
- category: DEVICE_FEATURE_CATEGORIES.SWITCH,
- type: DEVICE_FEATURE_TYPES.SWITCH.POWER,
- unit: DEVICE_FEATURE_UNITS.WATT,
- },
- [CUR_VOLTAGE]: {
- category: DEVICE_FEATURE_CATEGORIES.SWITCH,
- type: DEVICE_FEATURE_TYPES.SWITCH.VOLTAGE,
- unit: DEVICE_FEATURE_UNITS.VOLT,
- },
+ return Number.isNaN(parsedScale) ? defaultScale : parsedScale;
+};
+
+const scaleValue = (valueFromDevice, deviceFeature, defaultScale = 0) => {
+ const parsedValue = Number(valueFromDevice);
+ if (Number.isNaN(parsedValue)) {
+ return parsedValue;
+ }
+ const scale = getScale(deviceFeature, defaultScale);
+ return parsedValue / 10 ** scale;
};
const writeValues = {
@@ -127,6 +53,12 @@ const writeValues = {
},
},
+ [DEVICE_FEATURE_CATEGORIES.CHILD_LOCK]: {
+ [DEVICE_FEATURE_TYPES.CHILD_LOCK.BINARY]: (valueFromGladys) => {
+ return valueFromGladys === 1;
+ },
+ },
+
[DEVICE_FEATURE_CATEGORIES.CURTAIN]: {
[DEVICE_FEATURE_TYPES.CURTAIN.STATE]: (valueFromGladys) => {
if (valueFromGladys === COVER_STATE.OPEN) {
@@ -146,7 +78,7 @@ const writeValues = {
const readValues = {
[DEVICE_FEATURE_CATEGORIES.LIGHT]: {
[DEVICE_FEATURE_TYPES.LIGHT.BINARY]: (valueFromDevice) => {
- return valueFromDevice === true ? 1 : 0;
+ return normalizeBoolean(valueFromDevice) ? 1 : 0;
},
[DEVICE_FEATURE_TYPES.LIGHT.BRIGHTNESS]: (valueFromDevice) => {
return valueFromDevice;
@@ -164,19 +96,41 @@ const readValues = {
[DEVICE_FEATURE_CATEGORIES.SWITCH]: {
[DEVICE_FEATURE_TYPES.SWITCH.BINARY]: (valueFromDevice) => {
- return valueFromDevice === true ? 1 : 0;
+ return normalizeBoolean(valueFromDevice) ? 1 : 0;
+ },
+ [DEVICE_FEATURE_TYPES.SWITCH.ENERGY]: (valueFromDevice, deviceFeature) => {
+ return scaleValue(valueFromDevice, deviceFeature, 2);
+ },
+ [DEVICE_FEATURE_TYPES.SWITCH.CURRENT]: (valueFromDevice, deviceFeature) => {
+ return scaleValue(valueFromDevice, deviceFeature, 0);
+ },
+ [DEVICE_FEATURE_TYPES.SWITCH.POWER]: (valueFromDevice, deviceFeature) => {
+ return scaleValue(valueFromDevice, deviceFeature, 1);
+ },
+ [DEVICE_FEATURE_TYPES.SWITCH.VOLTAGE]: (valueFromDevice, deviceFeature) => {
+ return scaleValue(valueFromDevice, deviceFeature, 1);
+ },
+ },
+ [DEVICE_FEATURE_CATEGORIES.CHILD_LOCK]: {
+ [DEVICE_FEATURE_TYPES.CHILD_LOCK.BINARY]: (valueFromDevice) => {
+ return normalizeBoolean(valueFromDevice) ? 1 : 0;
+ },
+ },
+ [DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR]: {
+ [DEVICE_FEATURE_TYPES.ENERGY_SENSOR.POWER]: (valueFromDevice, deviceFeature) => {
+ return scaleValue(valueFromDevice, deviceFeature, 1);
},
- [DEVICE_FEATURE_TYPES.SWITCH.ENERGY]: (valueFromDevice) => {
- return parseInt(valueFromDevice, 10) / 100;
+ [DEVICE_FEATURE_TYPES.ENERGY_SENSOR.ENERGY]: (valueFromDevice, deviceFeature) => {
+ return scaleValue(valueFromDevice, deviceFeature, 2);
},
- [DEVICE_FEATURE_TYPES.SWITCH.CURRENT]: (valueFromDevice) => {
- return parseInt(valueFromDevice, 10);
+ [DEVICE_FEATURE_TYPES.ENERGY_SENSOR.EXPORT_INDEX]: (valueFromDevice, deviceFeature) => {
+ return scaleValue(valueFromDevice, deviceFeature, 2);
},
- [DEVICE_FEATURE_TYPES.SWITCH.POWER]: (valueFromDevice) => {
- return parseInt(valueFromDevice, 10) / 10;
+ [DEVICE_FEATURE_TYPES.ENERGY_SENSOR.VOLTAGE]: (valueFromDevice, deviceFeature) => {
+ return scaleValue(valueFromDevice, deviceFeature, 1);
},
- [DEVICE_FEATURE_TYPES.SWITCH.VOLTAGE]: (valueFromDevice) => {
- return parseInt(valueFromDevice, 10) / 10;
+ [DEVICE_FEATURE_TYPES.ENERGY_SENSOR.CURRENT]: (valueFromDevice, deviceFeature) => {
+ return scaleValue(valueFromDevice, deviceFeature, 0);
},
},
[DEVICE_FEATURE_CATEGORIES.CURTAIN]: {
@@ -195,4 +149,4 @@ const readValues = {
},
};
-module.exports = { mappings, readValues, writeValues };
+module.exports = { readValues, writeValues };
diff --git a/server/services/tuya/lib/device/tuya.localMapping.js b/server/services/tuya/lib/device/tuya.localMapping.js
new file mode 100644
index 0000000000..773134ba6a
--- /dev/null
+++ b/server/services/tuya/lib/device/tuya.localMapping.js
@@ -0,0 +1,85 @@
+const { convertFeature } = require('./tuya.convertFeature');
+const { getDeviceType, getLocalMapping, normalizeCode } = require('../mappings');
+
+const getLocalDpsFromCode = (code, device) => {
+ if (!code) {
+ return null;
+ }
+
+ const normalized = normalizeCode(code);
+ const deviceType = device && device.device_type ? device.device_type : getDeviceType(device);
+ const localMapping = getLocalMapping(deviceType);
+
+ if (localMapping.dps && localMapping.dps[normalized] !== undefined) {
+ return localMapping.dps[normalized];
+ }
+
+ const aliases = localMapping.codeAliases && localMapping.codeAliases[normalized];
+ if (Array.isArray(aliases)) {
+ const matchedAlias = aliases
+ .map((alias) => normalizeCode(alias))
+ .find((aliasCode) => aliasCode && localMapping.dps && localMapping.dps[aliasCode] !== undefined);
+
+ if (matchedAlias) {
+ return localMapping.dps[matchedAlias];
+ }
+ }
+
+ if (localMapping.strict) {
+ return null;
+ }
+
+ if (normalized === 'switch' || normalized === 'power') {
+ return 1;
+ }
+
+ const match = normalized.match(/_(\d+)$/);
+ if (match) {
+ return parseInt(match[1], 10);
+ }
+
+ return null;
+};
+
+const hasDpsKey = (dps, key) => {
+ if (!dps || typeof dps !== 'object') {
+ return false;
+ }
+ const stringKey = String(key);
+ return Object.prototype.hasOwnProperty.call(dps, stringKey) || Object.prototype.hasOwnProperty.call(dps, key);
+};
+
+const addFallbackBinaryFeature = (device, dps) => {
+ if (!device || !device.external_id) {
+ return device;
+ }
+
+ const hasFeatures = Array.isArray(device.features) && device.features.length > 0;
+ if (hasFeatures || !hasDpsKey(dps, 1)) {
+ return device;
+ }
+
+ const fallbackFeature = convertFeature(
+ {
+ code: 'switch_1',
+ values: '{}',
+ name: 'Switch',
+ readOnly: false,
+ },
+ device.external_id,
+ {
+ deviceType: device.device_type,
+ },
+ );
+
+ if (fallbackFeature) {
+ device.features = [fallbackFeature];
+ }
+
+ return device;
+};
+
+module.exports = {
+ addFallbackBinaryFeature,
+ getLocalDpsFromCode,
+};
diff --git a/server/services/tuya/lib/index.js b/server/services/tuya/lib/index.js
index baff4797c5..8c0d57441c 100644
--- a/server/services/tuya/lib/index.js
+++ b/server/services/tuya/lib/index.js
@@ -11,6 +11,10 @@ const { loadDevices } = require('./tuya.loadDevices');
const { loadDeviceDetails } = require('./tuya.loadDeviceDetails');
const { setValue } = require('./tuya.setValue');
const { poll } = require('./tuya.poll');
+const { localScan } = require('./tuya.localScan');
+const { localPoll } = require('./tuya.localPoll');
+const { getStatus } = require('./tuya.getStatus');
+const { manualDisconnect } = require('./tuya.manualDisconnect');
const { STATUS } = require('./utils/tuya.constants');
@@ -20,6 +24,8 @@ const TuyaHandler = function TuyaHandler(gladys, serviceId) {
this.connector = null;
this.status = STATUS.NOT_INITIALIZED;
+ this.lastError = null;
+ this.autoReconnectAllowed = false;
};
TuyaHandler.prototype.init = init;
@@ -35,5 +41,9 @@ TuyaHandler.prototype.loadDevices = loadDevices;
TuyaHandler.prototype.loadDeviceDetails = loadDeviceDetails;
TuyaHandler.prototype.setValue = setValue;
TuyaHandler.prototype.poll = poll;
+TuyaHandler.prototype.localScan = localScan;
+TuyaHandler.prototype.localPoll = localPoll;
+TuyaHandler.prototype.getStatus = getStatus;
+TuyaHandler.prototype.manualDisconnect = manualDisconnect;
module.exports = TuyaHandler;
diff --git a/server/services/tuya/lib/mappings/cloud/global.js b/server/services/tuya/lib/mappings/cloud/global.js
new file mode 100644
index 0000000000..bd76d84d2d
--- /dev/null
+++ b/server/services/tuya/lib/mappings/cloud/global.js
@@ -0,0 +1,80 @@
+const {
+ DEVICE_FEATURE_TYPES,
+ DEVICE_FEATURE_CATEGORIES,
+ DEVICE_FEATURE_UNITS,
+} = require('../../../../../utils/constants');
+
+module.exports = {
+ switch_led: {
+ category: DEVICE_FEATURE_CATEGORIES.LIGHT,
+ type: DEVICE_FEATURE_TYPES.LIGHT.BINARY,
+ },
+ switch: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ power: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ bright_value_v2: {
+ category: DEVICE_FEATURE_CATEGORIES.LIGHT,
+ type: DEVICE_FEATURE_TYPES.LIGHT.BRIGHTNESS,
+ },
+ temp_value_v2: {
+ category: DEVICE_FEATURE_CATEGORIES.LIGHT,
+ type: DEVICE_FEATURE_TYPES.LIGHT.TEMPERATURE,
+ },
+ colour_data_v2: {
+ category: DEVICE_FEATURE_CATEGORIES.LIGHT,
+ type: DEVICE_FEATURE_TYPES.LIGHT.COLOR,
+ },
+ colour_data: {
+ category: DEVICE_FEATURE_CATEGORIES.LIGHT,
+ type: DEVICE_FEATURE_TYPES.LIGHT.COLOR,
+ },
+ switch_1: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ switch_2: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ switch_3: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ switch_4: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ control: {
+ category: DEVICE_FEATURE_CATEGORIES.CURTAIN,
+ type: DEVICE_FEATURE_TYPES.CURTAIN.STATE,
+ },
+ percent_control: {
+ category: DEVICE_FEATURE_CATEGORIES.CURTAIN,
+ type: DEVICE_FEATURE_TYPES.CURTAIN.POSITION,
+ },
+ add_ele: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.ENERGY,
+ unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR,
+ },
+ cur_current: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.CURRENT,
+ unit: DEVICE_FEATURE_UNITS.MILLI_AMPERE,
+ },
+ cur_power: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.POWER,
+ unit: DEVICE_FEATURE_UNITS.WATT,
+ },
+ cur_voltage: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.VOLTAGE,
+ unit: DEVICE_FEATURE_UNITS.VOLT,
+ },
+};
diff --git a/server/services/tuya/lib/mappings/cloud/smart-meter.js b/server/services/tuya/lib/mappings/cloud/smart-meter.js
new file mode 100644
index 0000000000..c2cd3a7932
--- /dev/null
+++ b/server/services/tuya/lib/mappings/cloud/smart-meter.js
@@ -0,0 +1,91 @@
+const {
+ DEVICE_FEATURE_TYPES,
+ DEVICE_FEATURE_CATEGORIES,
+ DEVICE_FEATURE_UNITS,
+} = require('../../../../../utils/constants');
+
+module.exports = {
+ ignoredCodes: [
+ 'coef_a_reset',
+ 'coef_b_reset',
+ 'current_a_calibration',
+ 'current_b_calibration',
+ 'direction_a',
+ 'direction_b',
+ 'energy_a_calibration_fwd',
+ 'energy_a_calibration_rev',
+ 'energy_b_calibration_fwd',
+ 'energy_b_calibration_rev',
+ 'freq',
+ 'freq_calibration',
+ 'power_a_calibration',
+ 'power_b_calibration',
+ 'power_factor',
+ 'power_factor_b',
+ 'report_rate_control',
+ 'tbd',
+ 'voltage_coef',
+ ],
+ power_a: {
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.POWER,
+ unit: DEVICE_FEATURE_UNITS.WATT,
+ },
+ power_b: {
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.POWER,
+ unit: DEVICE_FEATURE_UNITS.WATT,
+ },
+ total_power: {
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.POWER,
+ unit: DEVICE_FEATURE_UNITS.WATT,
+ },
+ voltage_a: {
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.VOLTAGE,
+ unit: DEVICE_FEATURE_UNITS.VOLT,
+ },
+ current_a: {
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.CURRENT,
+ unit: DEVICE_FEATURE_UNITS.MILLI_AMPERE,
+ },
+ current_b: {
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.CURRENT,
+ unit: DEVICE_FEATURE_UNITS.MILLI_AMPERE,
+ },
+ energy_forword_a: {
+ // Intentional: matches Tuya device API code.
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.ENERGY,
+ unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR,
+ },
+ energy_forword_b: {
+ // Intentional: matches Tuya device API code.
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.ENERGY,
+ unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR,
+ },
+ forward_energy_total: {
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.ENERGY,
+ unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR,
+ },
+ energy_reverse_a: {
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.EXPORT_INDEX,
+ unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR,
+ },
+ energy_reserse_b: {
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.EXPORT_INDEX,
+ unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR,
+ },
+ reverse_energy_total: {
+ category: DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.ENERGY_SENSOR.EXPORT_INDEX,
+ unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR,
+ },
+};
diff --git a/server/services/tuya/lib/mappings/cloud/smart-socket.js b/server/services/tuya/lib/mappings/cloud/smart-socket.js
new file mode 100644
index 0000000000..49c7127bfa
--- /dev/null
+++ b/server/services/tuya/lib/mappings/cloud/smart-socket.js
@@ -0,0 +1,71 @@
+const {
+ DEVICE_FEATURE_TYPES,
+ DEVICE_FEATURE_CATEGORIES,
+ DEVICE_FEATURE_UNITS,
+} = require('../../../../../utils/constants');
+
+module.exports = {
+ ignoredCodes: [
+ 'countdown',
+ 'countdown_1',
+ 'relay_status',
+ 'overcharge_switch',
+ 'light_mode',
+ 'cycle_time',
+ 'random_time',
+ 'switch_inching',
+ 'voltage_coe',
+ 'electric_coe',
+ 'power_coe',
+ 'electricity_coe',
+ 'test_bit',
+ ],
+ switch: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ power: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ switch_1: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ switch_2: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ switch_3: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ switch_4: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ },
+ child_lock: {
+ category: DEVICE_FEATURE_CATEGORIES.CHILD_LOCK,
+ type: DEVICE_FEATURE_TYPES.CHILD_LOCK.BINARY,
+ },
+ add_ele: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.ENERGY,
+ unit: DEVICE_FEATURE_UNITS.KILOWATT_HOUR,
+ },
+ cur_current: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.CURRENT,
+ unit: DEVICE_FEATURE_UNITS.MILLI_AMPERE,
+ },
+ cur_power: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.POWER,
+ unit: DEVICE_FEATURE_UNITS.WATT,
+ },
+ cur_voltage: {
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.VOLTAGE,
+ unit: DEVICE_FEATURE_UNITS.VOLT,
+ },
+};
diff --git a/server/services/tuya/lib/mappings/index.js b/server/services/tuya/lib/mappings/index.js
new file mode 100644
index 0000000000..57eb3f09e5
--- /dev/null
+++ b/server/services/tuya/lib/mappings/index.js
@@ -0,0 +1,300 @@
+const globalCloud = require('./cloud/global');
+const smartMeterCloud = require('./cloud/smart-meter');
+const smartSocketCloud = require('./cloud/smart-socket');
+
+const globalLocal = require('./local/global');
+const smartMeterLocal = require('./local/smart-meter');
+const smartSocketLocal = require('./local/smart-socket');
+
+const DEVICE_TYPES = {
+ SMART_METER: 'smart-meter',
+ SMART_SOCKET: 'smart-socket',
+ UNKNOWN: 'unknown',
+};
+
+const SWITCH_CODES = new Set(['switch', 'switch_1', 'switch_2', 'power']);
+const SMART_METER_CODES = new Set(['total_power', 'forward_energy_total', 'voltage_a', 'current_a']);
+
+const SMART_SOCKET = {
+ DEVICE_TYPE_NAME: DEVICE_TYPES.SMART_SOCKET,
+ CATEGORIES: new Set(['cz']),
+ PRODUCT_IDS: new Set(['cya3zxfd38g4qp8d']),
+ KEYWORDS: ['socket', 'plug', 'outlet', 'prise'],
+ REQUIRED_CODES: SWITCH_CODES,
+ CLOUD_MAPPINGS: smartSocketCloud,
+ LOCAL_MAPPINGS: smartSocketLocal,
+};
+
+const SMART_METER = {
+ DEVICE_TYPE_NAME: DEVICE_TYPES.SMART_METER,
+ CATEGORIES: new Set(),
+ PRODUCT_IDS: new Set(['bbcg1hrkrj5rifsd']),
+ KEYWORDS: ['smart meter', 'meter'],
+ REQUIRED_CODES: SMART_METER_CODES,
+ CLOUD_MAPPINGS: smartMeterCloud,
+ LOCAL_MAPPINGS: smartMeterLocal,
+};
+
+const LIST_DEVICE_TYPES = [SMART_SOCKET, SMART_METER];
+
+const normalizeCode = (code) => {
+ if (!code) {
+ return null;
+ }
+ return String(code)
+ .trim()
+ .toLowerCase();
+};
+
+const normalizeStringSet = (setLike) =>
+ new Set(
+ Array.from(setLike || [])
+ .map((value) => normalizeCode(value))
+ .filter((value) => Boolean(value)),
+ );
+
+const DEVICE_TYPE_INDEX = LIST_DEVICE_TYPES.reduce((acc, definition) => {
+ const typeName = normalizeCode(definition && definition.DEVICE_TYPE_NAME);
+ acc[typeName] = {
+ ...definition,
+ CATEGORIES: normalizeStringSet(definition.CATEGORIES),
+ PRODUCT_IDS: normalizeStringSet(definition.PRODUCT_IDS),
+ KEYWORDS: Array.isArray(definition.KEYWORDS)
+ ? definition.KEYWORDS.map((keyword) => String(keyword).toLowerCase())
+ : [],
+ REQUIRED_CODES: normalizeStringSet(definition.REQUIRED_CODES),
+ };
+ return acc;
+}, {});
+
+const matchDeviceType = (typeDefinition, context) => {
+ const { category, productId, modelName, codes } = context;
+ if (productId && typeDefinition.PRODUCT_IDS.has(productId)) {
+ return true;
+ }
+
+ const requiredCodes = typeDefinition.REQUIRED_CODES;
+ const keywords = typeDefinition.KEYWORDS;
+ const hasRequiredCode = requiredCodes.size === 0 || Array.from(requiredCodes).some((code) => codes.has(code));
+
+ if (category && typeDefinition.CATEGORIES.has(category) && hasRequiredCode) {
+ return true;
+ }
+
+ if (!hasRequiredCode || !modelName || keywords.length === 0) {
+ return false;
+ }
+
+ return keywords.some((keyword) => modelName.includes(keyword));
+};
+
+const getCloudMapping = (deviceType) => {
+ if (!deviceType || deviceType === DEVICE_TYPES.UNKNOWN) {
+ return { ...globalCloud };
+ }
+ const definition = DEVICE_TYPE_INDEX[normalizeCode(deviceType)];
+ if (definition && definition.CLOUD_MAPPINGS) {
+ return { ...definition.CLOUD_MAPPINGS };
+ }
+ return { ...globalCloud };
+};
+
+const getLocalMapping = (deviceType) => {
+ const normalizeLocalMapping = (mapping) => {
+ const current = mapping && typeof mapping === 'object' ? mapping : {};
+ return {
+ strict: current.strict === true,
+ codeAliases: { ...(current.codeAliases || {}) },
+ dps: { ...(current.dps || {}) },
+ ignoredDps: Array.from(
+ new Set((Array.isArray(current.ignoredDps) ? current.ignoredDps : []).map((value) => String(value))),
+ ),
+ };
+ };
+
+ if (!deviceType || deviceType === DEVICE_TYPES.UNKNOWN) {
+ return normalizeLocalMapping(globalLocal);
+ }
+ const definition = DEVICE_TYPE_INDEX[normalizeCode(deviceType)];
+ if (definition && definition.LOCAL_MAPPINGS) {
+ return normalizeLocalMapping(definition.LOCAL_MAPPINGS);
+ }
+ return normalizeLocalMapping(globalLocal);
+};
+
+const extractCodesFromSpecifications = (specifications) => {
+ const codes = new Set();
+ if (!specifications || typeof specifications !== 'object') {
+ return codes;
+ }
+ const functions = Array.isArray(specifications.functions) ? specifications.functions : [];
+ const status = Array.isArray(specifications.status) ? specifications.status : [];
+ const properties = Array.isArray(specifications.properties) ? specifications.properties : [];
+
+ [...functions, ...status, ...properties].forEach((item) => {
+ if (!item || !item.code) {
+ return;
+ }
+ codes.add(normalizeCode(item.code));
+ });
+
+ return codes;
+};
+
+const extractCodesFromFeatures = (features) => {
+ const codes = new Set();
+ if (!Array.isArray(features)) {
+ return codes;
+ }
+
+ features.forEach((feature) => {
+ if (!feature || !feature.external_id) {
+ return;
+ }
+ const parts = String(feature.external_id).split(':');
+ if (parts.length >= 2) {
+ const code = normalizeCode(parts[parts.length - 1]);
+ if (code) {
+ codes.add(code);
+ }
+ }
+ });
+
+ return codes;
+};
+
+const extractCodesFromThingModel = (thingModel) => {
+ const codes = new Set();
+ const services = Array.isArray(thingModel && thingModel.services) ? thingModel.services : [];
+
+ services.forEach((service) => {
+ const properties = Array.isArray(service && service.properties) ? service.properties : [];
+ properties.forEach((property) => {
+ if (!property || !property.code) {
+ return;
+ }
+ codes.add(normalizeCode(property.code));
+ });
+ });
+
+ return codes;
+};
+
+const extractCodesFromProperties = (propertiesPayload) => {
+ const codes = new Set();
+ let properties = [];
+ if (Array.isArray(propertiesPayload)) {
+ properties = propertiesPayload;
+ } else if (Array.isArray(propertiesPayload && propertiesPayload.properties)) {
+ properties = propertiesPayload.properties;
+ }
+
+ properties.forEach((property) => {
+ if (!property || !property.code) {
+ return;
+ }
+ codes.add(normalizeCode(property.code));
+ });
+
+ return codes;
+};
+
+const extractCodesFromStatusList = (statusList) => {
+ const codes = new Set();
+ if (!Array.isArray(statusList)) {
+ return codes;
+ }
+
+ statusList.forEach((entry) => {
+ if (!entry || !entry.code) {
+ return;
+ }
+ codes.add(normalizeCode(entry.code));
+ });
+
+ return codes;
+};
+
+const getDeviceType = (device) => {
+ if (!device || typeof device !== 'object') {
+ return DEVICE_TYPES.UNKNOWN;
+ }
+
+ const specifications = device.specifications || {};
+ const codes = new Set([
+ ...extractCodesFromSpecifications(specifications),
+ ...extractCodesFromThingModel(device.thing_model),
+ ...extractCodesFromProperties(device.properties),
+ ...extractCodesFromStatusList(device.status),
+ ...extractCodesFromFeatures(device.features),
+ ]);
+
+ const modelName = [device.model, device.product_name, device.name]
+ .filter((value) => typeof value === 'string' && value.length > 0)
+ .join(' ')
+ .toLowerCase();
+ const category = normalizeCode(specifications.category || device.category);
+ const productId = normalizeCode(device.product_id);
+ const context = {
+ codes,
+ modelName,
+ category,
+ productId,
+ };
+
+ const matchedType = Object.values(DEVICE_TYPE_INDEX).find((typeDefinition) =>
+ matchDeviceType(typeDefinition, context),
+ );
+ if (matchedType && matchedType.DEVICE_TYPE_NAME) {
+ return matchedType.DEVICE_TYPE_NAME;
+ }
+
+ return DEVICE_TYPES.UNKNOWN;
+};
+
+const getFeatureMapping = (code, deviceType) => {
+ const normalized = normalizeCode(code);
+ if (!normalized) {
+ return null;
+ }
+ const mapping = getCloudMapping(deviceType);
+ const candidate = mapping[normalized];
+
+ if (!candidate || typeof candidate !== 'object') {
+ return null;
+ }
+ if (!candidate.category || !candidate.type) {
+ return null;
+ }
+
+ return candidate;
+};
+
+const getIgnoredLocalDps = (deviceType) => {
+ const { ignoredDps } = getLocalMapping(deviceType);
+ return Array.isArray(ignoredDps) ? ignoredDps : [];
+};
+
+const getIgnoredCloudCodes = (deviceType) => {
+ const mapping = getCloudMapping(deviceType);
+ const ignored = Array.isArray(mapping.ignoredCodes) ? mapping.ignoredCodes : [];
+ const normalized = ignored
+ .filter((value) => value !== null && value !== undefined)
+ .map((value) => String(value).toLowerCase());
+
+ return Array.from(new Set(normalized));
+};
+
+module.exports = {
+ DEVICE_TYPES,
+ extractCodesFromSpecifications,
+ extractCodesFromFeatures,
+ extractCodesFromStatusList,
+ getCloudMapping,
+ getLocalMapping,
+ getFeatureMapping,
+ getIgnoredLocalDps,
+ getIgnoredCloudCodes,
+ getDeviceType,
+ normalizeCode,
+};
diff --git a/server/services/tuya/lib/mappings/local/global.js b/server/services/tuya/lib/mappings/local/global.js
new file mode 100644
index 0000000000..b83276f9bf
--- /dev/null
+++ b/server/services/tuya/lib/mappings/local/global.js
@@ -0,0 +1,11 @@
+module.exports = {
+ strict: false,
+ codeAliases: {
+ switch: ['power'],
+ power: ['switch'],
+ },
+ dps: {
+ switch: 1,
+ power: 1,
+ },
+};
diff --git a/server/services/tuya/lib/mappings/local/smart-meter.js b/server/services/tuya/lib/mappings/local/smart-meter.js
new file mode 100644
index 0000000000..6a7d13db94
--- /dev/null
+++ b/server/services/tuya/lib/mappings/local/smart-meter.js
@@ -0,0 +1,38 @@
+module.exports = {
+ strict: true,
+ ignoredDps: [
+ '102',
+ '103',
+ '104',
+ '110',
+ '116',
+ '117',
+ '118',
+ '119',
+ '120',
+ '121',
+ '122',
+ '123',
+ '124',
+ '125',
+ '126',
+ '127',
+ '128',
+ '129',
+ ],
+ codeAliases: {},
+ dps: {
+ power_a: 101,
+ power_b: 105,
+ energy_forword_a: 106,
+ energy_reverse_a: 107,
+ energy_forword_b: 108,
+ energy_reserse_b: 109,
+ voltage_a: 112,
+ current_a: 113,
+ current_b: 114,
+ total_power: 115,
+ forward_energy_total: 130,
+ reverse_energy_total: 131,
+ },
+};
diff --git a/server/services/tuya/lib/mappings/local/smart-socket.js b/server/services/tuya/lib/mappings/local/smart-socket.js
new file mode 100644
index 0000000000..7419ddba10
--- /dev/null
+++ b/server/services/tuya/lib/mappings/local/smart-socket.js
@@ -0,0 +1,26 @@
+module.exports = {
+ strict: true,
+ ignoredDps: ['9', '11', '21', '22', '23', '24', '25', '38', '39', '40', '42', '43', '44'],
+ codeAliases: {
+ child_lock: [],
+ switch: ['power'],
+ power: ['switch'],
+ switch_1: ['switch', 'power'],
+ switch_2: ['switch'],
+ switch_3: ['switch'],
+ switch_4: ['switch'],
+ },
+ dps: {
+ add_ele: 17,
+ cur_current: 18,
+ cur_power: 19,
+ cur_voltage: 20,
+ child_lock: 41,
+ switch: 1,
+ power: 1,
+ switch_1: 1,
+ switch_2: 2,
+ switch_3: 3,
+ switch_4: 4,
+ },
+};
diff --git a/server/services/tuya/lib/tuya.connect.js b/server/services/tuya/lib/tuya.connect.js
index af1e0d433e..9fc103544d 100644
--- a/server/services/tuya/lib/tuya.connect.js
+++ b/server/services/tuya/lib/tuya.connect.js
@@ -4,7 +4,78 @@ const logger = require('../../../utils/logger');
const { ServiceNotConfiguredError } = require('../../../utils/coreErrors');
const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants');
-const { STATUS } = require('./utils/tuya.constants');
+const { STATUS, GLADYS_VARIABLES, API } = require('./utils/tuya.constants');
+const { buildConfigHash } = require('./utils/tuya.config');
+
+/**
+ * @description Map Tuya errors to user-facing i18n keys and retry policy.
+ * @param {Error} error - Error thrown during connection.
+ * @returns {object|null} Mapping info or null when unknown.
+ * @example
+ * const mapped = mapConnectionError(new Error('GET_TOKEN_FAILED 2009, clientId is invalid'));
+ */
+const mapConnectionError = (error) => {
+ const rawMessage = error && error.message ? error.message : '';
+ const message = rawMessage.toLowerCase();
+ const code = error && error.code ? String(error.code).toLowerCase() : '';
+
+ if (code === '2009' || message.includes('clientid is invalid') || message.includes('get_token_failed 2009')) {
+ return { key: 'integration.tuya.setup.errorInvalidClientId', disableAutoReconnect: true };
+ }
+
+ if (code === '1004' || message.includes('sign invalid') || message.includes('get_token_failed 1004')) {
+ return { key: 'integration.tuya.setup.errorInvalidClientSecret', disableAutoReconnect: true };
+ }
+
+ if (code === '28841107' || message.includes('data center is suspended') || message.includes('data center')) {
+ return { key: 'integration.tuya.setup.errorInvalidEndpoint', disableAutoReconnect: true };
+ }
+
+ if (
+ code === '1106' ||
+ message.includes('permission deny') ||
+ code === 'tuya_app_account_uid_missing' ||
+ code === 'tuya_app_account_uid_invalid'
+ ) {
+ return { key: 'integration.tuya.setup.errorInvalidAppAccountUid', disableAutoReconnect: true };
+ }
+
+ return null;
+};
+
+/**
+ * @description Validate Tuya app account UID by calling the devices endpoint.
+ * @param {object} connector - Tuya connector instance.
+ * @param {string} appAccountId - Tuya app account UID.
+ * @returns {Promise} Resolves when valid.
+ * @example
+ * await validateAppAccount(connector, 'uid');
+ */
+const validateAppAccount = async (connector, appAccountId) => {
+ if (!appAccountId) {
+ const error = new Error('TUYA_APP_ACCOUNT_UID_MISSING');
+ error.code = 'TUYA_APP_ACCOUNT_UID_MISSING';
+ throw error;
+ }
+ const response = await connector.request({
+ method: 'GET',
+ path: `${API.PUBLIC_VERSION_1_0}/users/${appAccountId}/devices`,
+ query: {
+ page_no: 1,
+ page_size: 1,
+ },
+ });
+ if (!response) {
+ const error = new Error('TUYA_APP_ACCOUNT_UID_INVALID');
+ error.code = 'TUYA_APP_ACCOUNT_UID_INVALID';
+ throw error;
+ }
+ if (response && response.success === false) {
+ const error = new Error(response.msg || response.message || 'TUYA_APP_ACCOUNT_UID_INVALID');
+ error.code = response.code || 'TUYA_APP_ACCOUNT_UID_INVALID';
+ throw error;
+ }
+};
/**
* @description Connect to Tuya cloud.
@@ -13,7 +84,7 @@ const { STATUS } = require('./utils/tuya.constants');
* connect({baseUrl, accessKey, secretKey});
*/
async function connect(configuration) {
- const { baseUrl, accessKey, secretKey } = configuration;
+ const { baseUrl, accessKey, secretKey, appAccountId } = configuration;
if (!baseUrl || !accessKey || !secretKey) {
this.status = STATUS.NOT_INITIALIZED;
@@ -21,6 +92,7 @@ async function connect(configuration) {
}
this.status = STATUS.CONNECTING;
+ this.lastError = null;
this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, {
type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS,
payload: { status: STATUS.CONNECTING },
@@ -35,17 +107,37 @@ async function connect(configuration) {
});
try {
- this.connector.client.init();
+ await this.connector.client.init();
+ await validateAppAccount(this.connector, appAccountId);
+ await this.gladys.variable.setValue(GLADYS_VARIABLES.MANUAL_DISCONNECT, 'false', this.serviceId);
+ const configHash = buildConfigHash(configuration);
+ await this.gladys.variable.setValue(GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, configHash, this.serviceId);
+ this.autoReconnectAllowed = true;
this.status = STATUS.CONNECTED;
logger.debug('Connected to Tuya');
} catch (e) {
this.status = STATUS.ERROR;
+ const mapped = mapConnectionError(e);
+ let message = 'Unknown error';
+ if (mapped) {
+ message = mapped.key;
+ } else if (e && e.message) {
+ message = e.message;
+ }
+ this.lastError = message;
+ if (mapped && mapped.disableAutoReconnect) {
+ this.autoReconnectAllowed = false;
+ }
logger.error('Error connecting to Tuya:', e);
+ this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, {
+ type: WEBSOCKET_MESSAGE_TYPES.TUYA.ERROR,
+ payload: { message },
+ });
}
this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, {
type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS,
- payload: { status: this.status },
+ payload: { status: this.status, error: this.lastError },
});
}
diff --git a/server/services/tuya/lib/tuya.disconnect.js b/server/services/tuya/lib/tuya.disconnect.js
index 1406c585be..23edccbf76 100644
--- a/server/services/tuya/lib/tuya.disconnect.js
+++ b/server/services/tuya/lib/tuya.disconnect.js
@@ -1,15 +1,25 @@
const logger = require('../../../utils/logger');
+const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants');
const { STATUS } = require('./utils/tuya.constants');
/**
* @description Disconnects service and dependencies.
+ * @param {object} [options] - Disconnect options.
+ * @param {boolean} [options.manual] - Whether this is a manual disconnect.
* @example
* disconnect();
*/
-function disconnect() {
- logger.debug('Disonnecting from Tuya...');
+function disconnect(options = {}) {
+ logger.debug('Disconnecting from Tuya...');
+ const { manual = false } = options;
this.connector = null;
this.status = STATUS.NOT_INITIALIZED;
+ this.lastError = null;
+
+ this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, {
+ type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS,
+ payload: { status: this.status, manual_disconnect: manual },
+ });
}
module.exports = {
diff --git a/server/services/tuya/lib/tuya.discoverDevices.js b/server/services/tuya/lib/tuya.discoverDevices.js
index 333e6bb4ee..5b6a261577 100644
--- a/server/services/tuya/lib/tuya.discoverDevices.js
+++ b/server/services/tuya/lib/tuya.discoverDevices.js
@@ -1,9 +1,11 @@
const logger = require('../../../utils/logger');
const { ServiceNotConfiguredError } = require('../../../utils/coreErrors');
const { WEBSOCKET_MESSAGE_TYPES, EVENTS } = require('../../../utils/constants');
+const { mergeDevices } = require('../../../utils/device');
const { STATUS } = require('./utils/tuya.constants');
const { convertDevice } = require('./device/tuya.convertDevice');
+const { applyExistingLocalParams, normalizeExistingDevice } = require('./utils/tuya.deviceParams');
/**
* @description Discover Tuya cloud devices.
@@ -42,16 +44,40 @@ async function discoverDevices() {
devices.map((device) => this.loadDeviceDetails(device)),
).then((results) => results.filter((result) => result.status === 'fulfilled').map((result) => result.value));
+ this.discoveredDevices = this.discoveredDevices.map((device) => {
+ const cloudIp = device.cloud_ip || device.ip;
+ return {
+ ...device,
+ cloud_ip: cloudIp,
+ ip: null,
+ protocol_version: null,
+ local_override: false,
+ };
+ });
+
this.discoveredDevices = this.discoveredDevices
.map((device) => ({
...convertDevice(device),
service_id: this.serviceId,
}))
- .filter((device) => {
- const existInGladys = this.gladys.stateManager.get('deviceByExternalId', device.external_id);
- return existInGladys === null;
+ .map((device) => {
+ const existing = normalizeExistingDevice(this.gladys.stateManager.get('deviceByExternalId', device.external_id));
+ const deviceWithLocalParams = applyExistingLocalParams(device, existing);
+ return mergeDevices(deviceWithLocalParams, existing);
});
+ try {
+ const existingDevices = await this.gladys.device.get({ service: 'tuya' });
+ const discoveredByExternalId = new Map(this.discoveredDevices.map((device) => [device.external_id, device]));
+ existingDevices.forEach((device) => {
+ if (device && device.external_id && !discoveredByExternalId.has(device.external_id)) {
+ this.discoveredDevices.push({ ...device, updatable: false });
+ }
+ });
+ } catch (e) {
+ logger.warn('Unable to load existing Tuya devices from Gladys', e);
+ }
+
this.status = STATUS.CONNECTED;
this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, {
diff --git a/server/services/tuya/lib/tuya.getConfiguration.js b/server/services/tuya/lib/tuya.getConfiguration.js
index 4b00a0a04e..a5dbe4954a 100644
--- a/server/services/tuya/lib/tuya.getConfiguration.js
+++ b/server/services/tuya/lib/tuya.getConfiguration.js
@@ -13,6 +13,8 @@ async function getConfiguration() {
const endpoint = await this.gladys.variable.getValue(GLADYS_VARIABLES.ENDPOINT, this.serviceId);
const accessKey = await this.gladys.variable.getValue(GLADYS_VARIABLES.ACCESS_KEY, this.serviceId);
const secretKey = await this.gladys.variable.getValue(GLADYS_VARIABLES.SECRET_KEY, this.serviceId);
+ const appAccountId = await this.gladys.variable.getValue(GLADYS_VARIABLES.APP_ACCOUNT_UID, this.serviceId);
+ const appUsername = await this.gladys.variable.getValue(GLADYS_VARIABLES.APP_USERNAME, this.serviceId);
logger.debug(`Tuya configuration: baseUrl='${endpoint}' accessKey='${accessKey}'`);
const baseUrl = TUYA_ENDPOINTS[endpoint] || TUYA_ENDPOINTS.china;
@@ -21,6 +23,9 @@ async function getConfiguration() {
baseUrl,
accessKey,
secretKey,
+ appUsername,
+ endpoint,
+ appAccountId,
};
}
diff --git a/server/services/tuya/lib/tuya.getStatus.js b/server/services/tuya/lib/tuya.getStatus.js
new file mode 100644
index 0000000000..3d2914dbaf
--- /dev/null
+++ b/server/services/tuya/lib/tuya.getStatus.js
@@ -0,0 +1,31 @@
+const { GLADYS_VARIABLES, STATUS } = require('./utils/tuya.constants');
+const { normalizeBoolean } = require('./utils/tuya.normalize');
+
+/**
+ * @description Get Tuya connection and configuration status.
+ * @returns {Promise} Status object.
+ * @example
+ * const status = await getStatus();
+ */
+async function getStatus() {
+ const endpoint = await this.gladys.variable.getValue(GLADYS_VARIABLES.ENDPOINT, this.serviceId);
+ const accessKey = await this.gladys.variable.getValue(GLADYS_VARIABLES.ACCESS_KEY, this.serviceId);
+ const secretKey = await this.gladys.variable.getValue(GLADYS_VARIABLES.SECRET_KEY, this.serviceId);
+ const appAccountId = await this.gladys.variable.getValue(GLADYS_VARIABLES.APP_ACCOUNT_UID, this.serviceId);
+ const manualDisconnect = await this.gladys.variable.getValue(GLADYS_VARIABLES.MANUAL_DISCONNECT, this.serviceId);
+
+ const configured = Boolean(endpoint && accessKey && secretKey && appAccountId);
+ const manualDisconnectEnabled = normalizeBoolean(manualDisconnect);
+
+ return {
+ status: this.status || STATUS.NOT_INITIALIZED,
+ connected: this.status === STATUS.CONNECTED,
+ configured,
+ error: this.lastError,
+ manual_disconnect: manualDisconnectEnabled,
+ };
+}
+
+module.exports = {
+ getStatus,
+};
diff --git a/server/services/tuya/lib/tuya.init.js b/server/services/tuya/lib/tuya.init.js
index a4cee62c93..4311b80994 100644
--- a/server/services/tuya/lib/tuya.init.js
+++ b/server/services/tuya/lib/tuya.init.js
@@ -1,3 +1,8 @@
+const { GLADYS_VARIABLES, STATUS } = require('./utils/tuya.constants');
+const { buildConfigHash } = require('./utils/tuya.config');
+const { normalizeBoolean } = require('./utils/tuya.normalize');
+const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../utils/constants');
+
/**
* @description Initialize service with properties and connect to devices.
* @example
@@ -5,6 +10,27 @@
*/
async function init() {
const configuration = await this.getConfiguration();
+ const lastConnectedHash = await this.gladys.variable.getValue(
+ GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH,
+ this.serviceId,
+ );
+ const currentHash = configuration ? buildConfigHash(configuration) : null;
+ const hasMatchingConfig = Boolean(lastConnectedHash && currentHash && lastConnectedHash === currentHash);
+ const manualDisconnect = await this.gladys.variable.getValue(GLADYS_VARIABLES.MANUAL_DISCONNECT, this.serviceId);
+ const manualDisconnectEnabled = normalizeBoolean(manualDisconnect);
+
+ if (manualDisconnectEnabled) {
+ this.autoReconnectAllowed = false;
+ this.status = STATUS.NOT_INITIALIZED;
+ this.lastError = null;
+ this.gladys.event.emit(EVENTS.WEBSOCKET.SEND_ALL, {
+ type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS,
+ payload: { status: this.status, manual_disconnect: true },
+ });
+ return;
+ }
+ this.autoReconnectAllowed = hasMatchingConfig;
+
await this.connect(configuration);
}
diff --git a/server/services/tuya/lib/tuya.loadDeviceDetails.js b/server/services/tuya/lib/tuya.loadDeviceDetails.js
index 774910f65d..c832a79011 100644
--- a/server/services/tuya/lib/tuya.loadDeviceDetails.js
+++ b/server/services/tuya/lib/tuya.loadDeviceDetails.js
@@ -1,5 +1,6 @@
const logger = require('../../../utils/logger');
const { API } = require('./utils/tuya.constants');
+const { buildCloudReport } = require('./utils/tuya.report');
/**
* @description Load Tuya device details.
@@ -10,15 +11,89 @@ const { API } = require('./utils/tuya.constants');
*/
async function loadDeviceDetails(tuyaDevice) {
const { id: deviceId } = tuyaDevice;
+ const listDeviceEntry = tuyaDevice ? { ...tuyaDevice } : null;
logger.debug(`Loading ${deviceId} Tuya device specifications`);
- const responsePage = await this.connector.request({
- method: 'GET',
- path: `${API.VERSION_1_2}/devices/${deviceId}/specification`,
- });
+ const [specResult, detailsResult, propsResult, modelResult] = await Promise.allSettled([
+ this.connector.request({
+ method: 'GET',
+ path: `${API.VERSION_1_2}/devices/${deviceId}/specification`,
+ }),
+ this.connector.request({
+ method: 'GET',
+ path: `${API.VERSION_1_0}/devices/${deviceId}`,
+ }),
+ this.connector.request({
+ method: 'GET',
+ path: `${API.VERSION_2_0}/thing/${deviceId}/shadow/properties`,
+ }),
+ this.connector.request({
+ method: 'GET',
+ path: `${API.VERSION_2_0}/thing/${deviceId}/model`,
+ }),
+ ]);
- const { result } = responsePage;
- return { ...tuyaDevice, specifications: result };
+ if (specResult.status === 'rejected') {
+ const reason = specResult.reason && specResult.reason.message ? specResult.reason.message : specResult.reason;
+ logger.warn(`[Tuya] Failed to load specifications for ${deviceId}: ${reason}`);
+ }
+ if (detailsResult.status === 'rejected') {
+ const reason =
+ detailsResult.reason && detailsResult.reason.message ? detailsResult.reason.message : detailsResult.reason;
+ logger.warn(`[Tuya] Failed to load details for ${deviceId}: ${reason}`);
+ }
+ if (propsResult.status === 'rejected') {
+ const reason = propsResult.reason && propsResult.reason.message ? propsResult.reason.message : propsResult.reason;
+ logger.warn(`[Tuya] Failed to load properties for ${deviceId}: ${reason}`);
+ }
+ if (modelResult.status === 'rejected') {
+ const reason = modelResult.reason && modelResult.reason.message ? modelResult.reason.message : modelResult.reason;
+ logger.warn(`[Tuya] Failed to load thing model for ${deviceId}: ${reason}`);
+ }
+
+ const specifications = specResult.status === 'fulfilled' ? (specResult.value && specResult.value.result) || {} : {};
+ const details = detailsResult.status === 'fulfilled' ? (detailsResult.value && detailsResult.value.result) || {} : {};
+ const properties = propsResult.status === 'fulfilled' ? (propsResult.value && propsResult.value.result) || {} : {};
+ const modelPayload =
+ modelResult.status === 'fulfilled' ? (modelResult.value && modelResult.value.result) || null : null;
+ const rawModel =
+ modelPayload && Object.prototype.hasOwnProperty.call(modelPayload, 'model') ? modelPayload.model : modelPayload;
+ let thingModel = null;
+ if (typeof rawModel === 'string') {
+ try {
+ thingModel = JSON.parse(rawModel);
+ } catch (e) {
+ logger.warn(`[Tuya] Invalid thing model JSON for ${deviceId}`, e);
+ thingModel = null;
+ }
+ } else if (rawModel && typeof rawModel === 'object') {
+ thingModel = rawModel;
+ }
+
+ const category = details.category || tuyaDevice.category;
+ const specificationsWithCategory =
+ category && !specifications.category ? { ...specifications, category } : specifications;
+
+ const deviceWithDetails = {
+ ...tuyaDevice,
+ ...details,
+ specifications: specificationsWithCategory,
+ properties,
+ thing_model: thingModel,
+ };
+
+ return {
+ ...deviceWithDetails,
+ tuya_report: buildCloudReport({
+ deviceId,
+ listDeviceEntry,
+ specResult,
+ detailsResult,
+ propsResult,
+ modelResult,
+ device: deviceWithDetails,
+ }),
+ };
}
module.exports = {
diff --git a/server/services/tuya/lib/tuya.loadDevices.js b/server/services/tuya/lib/tuya.loadDevices.js
index 09da27c327..2b14aed438 100644
--- a/server/services/tuya/lib/tuya.loadDevices.js
+++ b/server/services/tuya/lib/tuya.loadDevices.js
@@ -3,33 +3,61 @@ const { API, GLADYS_VARIABLES } = require('./utils/tuya.constants');
/**
* @description Discover Tuya cloud devices.
- * @param {string} lastRowKey - Key of last row to start with.
+ * @param {number} pageNo - Page number.
+ * @param {number} pageSize - Page size.
* @returns {Promise} List of discovered devices.
* @example
* await loadDevices();
*/
-async function loadDevices(lastRowKey = null) {
+async function loadDevices(pageNo = 1, pageSize = 100) {
+ if (!Number.isInteger(pageNo) || pageNo <= 0) {
+ throw new Error('pageNo must be a positive integer');
+ }
+ if (!Number.isInteger(pageSize) || pageSize <= 0) {
+ throw new Error('pageSize must be a positive integer');
+ }
const sourceId = await this.gladys.variable.getValue(GLADYS_VARIABLES.APP_ACCOUNT_UID, this.serviceId);
+ if (!sourceId) {
+ throw new Error('Tuya APP_ACCOUNT_UID is missing');
+ }
const responsePage = await this.connector.request({
method: 'GET',
- path: `${API.VERSION_1_3}/devices`,
+ path: `${API.PUBLIC_VERSION_1_0}/users/${sourceId}/devices`,
query: {
- last_row_key: lastRowKey,
- source_type: 'tuyaUser',
- source_id: sourceId,
+ page_no: pageNo,
+ page_size: pageSize,
},
});
+ if (!responsePage) {
+ throw new Error('Tuya API returned no response');
+ }
+ if (responsePage.success === false) {
+ const message = responsePage.msg || responsePage.message || responsePage.code || 'Tuya API error';
+ throw new Error(message);
+ }
- const { result } = responsePage;
- const { list, has_more: hasMore, last_row_key: nextLastRowKey, total } = result;
+ const result = responsePage.result || [];
+ let list = [];
+ if (Array.isArray(result)) {
+ list = result;
+ } else if (Array.isArray(result && result.list)) {
+ list = result.list;
+ }
+ let hasMore = list.length === pageSize;
+ if (!Array.isArray(result) && typeof result.has_more === 'boolean') {
+ hasMore = result.has_more;
+ }
if (hasMore) {
- const nextResult = await this.loadDevices(nextLastRowKey);
- nextResult.forEach((device) => list.push(device));
+ if (list.length === 0) {
+ throw new Error('Tuya API pagination did not advance (has_more=true with empty page)');
+ }
+ const nextResult = await this.loadDevices(pageNo + 1, pageSize);
+ list.push(...nextResult);
}
- logger.debug(`${list.length} / ${total} Tuya devices loaded`);
+ logger.debug(`${list.length} Tuya devices loaded`);
return list;
}
diff --git a/server/services/tuya/lib/tuya.localPoll.js b/server/services/tuya/lib/tuya.localPoll.js
new file mode 100644
index 0000000000..3d45383808
--- /dev/null
+++ b/server/services/tuya/lib/tuya.localPoll.js
@@ -0,0 +1,248 @@
+const TuyAPI = require('tuyapi');
+const TuyAPINewGen = require('@demirdeniz/tuyapi-newgen');
+const logger = require('../../../utils/logger');
+const { BadParameters } = require('../../../utils/coreErrors');
+const { mergeDevices } = require('../../../utils/device');
+const { DEVICE_PARAM_NAME } = require('./utils/tuya.constants');
+const { normalizeExistingDevice, upsertParam, getParamValue } = require('./utils/tuya.deviceParams');
+const { addFallbackBinaryFeature } = require('./device/tuya.localMapping');
+const { convertDevice } = require('./device/tuya.convertDevice');
+
+/**
+ * @description Poll a Tuya device locally to retrieve DPS map.
+ * @param {object} payload - Local connection info.
+ * @returns {Promise} DPS map.
+ * @example
+ * await localPoll({ deviceId: 'id', ip: '1.1.1.1', localKey: 'key', protocolVersion: '3.3' });
+ */
+async function localPoll(payload) {
+ const { deviceId, ip, localKey, protocolVersion, timeoutMs = 3000, fastScan = false, logDps = true } = payload || {};
+ const isProtocol34 = protocolVersion === '3.4';
+ const isProtocol35 = protocolVersion === '3.5';
+ const isProtocol34Or35 = isProtocol34 || isProtocol35;
+ const parsedTimeout = Number(timeoutMs);
+ const sanitizedTimeout = Number.isFinite(parsedTimeout) ? Math.min(Math.max(parsedTimeout, 500), 30000) : 3000;
+ const effectiveTimeout = isProtocol34Or35 && !fastScan ? Math.max(sanitizedTimeout, 5000) : sanitizedTimeout;
+ const TuyaLocalApi = isProtocol35 ? TuyAPINewGen : TuyAPI;
+
+ if (!deviceId || !ip || !localKey || !protocolVersion) {
+ throw new BadParameters('Missing local connection parameters');
+ }
+
+ const tuyaOptions = {
+ id: deviceId,
+ key: localKey,
+ ip,
+ version: protocolVersion,
+ issueGetOnConnect: false,
+ issueRefreshOnConnect: false,
+ issueRefreshOnPing: false,
+ };
+ if (isProtocol35) {
+ tuyaOptions.keepAlive = false;
+ tuyaOptions.socketTimeout = Math.max(effectiveTimeout, 5000);
+ }
+ const tuyaLocal = new TuyaLocalApi(tuyaOptions);
+ let lastError = null;
+ const formatSocketError = (err) => {
+ if (!err || !err.message) {
+ return 'Local poll socket error';
+ }
+ const networkErrorCodes = ['EHOSTUNREACH', 'EHOSTDOWN', 'ENETUNREACH', 'ECONNREFUSED', 'ETIMEDOUT'];
+ if (networkErrorCodes.includes(err.code)) {
+ return `Local device unreachable at ${ip}:6668 (${err.code}). Device may be offline, unplugged, or no longer connected to Wi-Fi.`;
+ }
+ if (typeof err.message === 'string' && err.message.includes('EHOSTUNREACH')) {
+ return `Local device unreachable at ${ip}:6668 (EHOSTUNREACH). Device may be offline, unplugged, or no longer connected to Wi-Fi.`;
+ }
+ return `Local poll socket error: ${err.message}`;
+ };
+ const onError = (err) => {
+ lastError = err;
+ logger.info(`[Tuya][localPoll] socket error for device=${deviceId}: ${formatSocketError(err)}`);
+ };
+ tuyaLocal.on('error', onError);
+
+ const runGet = async (options) => {
+ let errorListener;
+ let timeoutId;
+ let resolved = false;
+ const cleanup = async () => {
+ if (resolved) {
+ return;
+ }
+ resolved = true;
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ if (errorListener) {
+ tuyaLocal.removeListener('error', errorListener);
+ }
+ try {
+ await tuyaLocal.disconnect();
+ } catch (err) {
+ // ignore
+ }
+ };
+ try {
+ const operation = (async () => {
+ await tuyaLocal.connect();
+ const data = await tuyaLocal.get(options);
+ return data;
+ })();
+ const data = await Promise.race([
+ operation,
+ new Promise((_, reject) => {
+ timeoutId = setTimeout(() => reject(new BadParameters('Local poll timeout')), effectiveTimeout);
+ }),
+ new Promise((_, reject) => {
+ errorListener = (err) => {
+ reject(new BadParameters(formatSocketError(err)));
+ };
+ tuyaLocal.once('error', errorListener);
+ }),
+ ]);
+ await cleanup();
+ return data;
+ } catch (e) {
+ await cleanup();
+ throw e;
+ }
+ };
+
+ try {
+ let attempts = [{ schema: true }];
+ if (isProtocol35) {
+ attempts = [{ schema: true }, { schema: true, dps: [1] }, {}];
+ } else if (isProtocol34) {
+ attempts = [{ schema: true }, { schema: true }];
+ }
+ const tryAttempt = async (index) => {
+ try {
+ return await runGet(attempts[index]);
+ } catch (e) {
+ if (index >= attempts.length - 1) {
+ throw e;
+ }
+ return tryAttempt(index + 1);
+ }
+ };
+ const data = await tryAttempt(0);
+ if (!data || typeof data !== 'object' || !data.dps) {
+ const errorMessage =
+ typeof data === 'string' ? `Invalid local poll response: ${data}` : 'Invalid local poll response';
+ throw new BadParameters(errorMessage);
+ }
+ if (logDps) {
+ logger.debug(`[Tuya][localPoll] device=${deviceId} dps=${JSON.stringify(data)}`);
+ }
+ return data;
+ } catch (e) {
+ if (lastError && (!e || e.message !== lastError.message)) {
+ logger.info(`[Tuya][localPoll] last socket error for device=${deviceId}: ${formatSocketError(lastError)}`);
+ }
+ logger.warn(`[Tuya][localPoll] failed for device=${deviceId}`, e);
+ try {
+ await tuyaLocal.disconnect();
+ } catch (err) {
+ // ignore
+ }
+ throw e;
+ }
+}
+
+/**
+ * @description Update discovered device list after a successful local poll.
+ * @param {object} tuyaManager - Tuya handler instance.
+ * @param {object} payload - Local poll payload.
+ * @returns {object|null} Updated device when found.
+ * @example
+ * updateDiscoveredDeviceAfterLocalPoll(tuyaManager, { deviceId: 'id', ip: '1.1.1.1', protocolVersion: '3.3' });
+ */
+function updateDiscoveredDeviceAfterLocalPoll(tuyaManager, payload) {
+ const { deviceId, ip, protocolVersion, localKey, dps } = payload || {};
+ if (!deviceId || !tuyaManager || !Array.isArray(tuyaManager.discoveredDevices)) {
+ return null;
+ }
+ const externalId = `tuya:${deviceId}`;
+ const deviceIndex = tuyaManager.discoveredDevices.findIndex((device) => device.external_id === externalId);
+ if (deviceIndex < 0) {
+ return null;
+ }
+
+ let device = { ...tuyaManager.discoveredDevices[deviceIndex] };
+ const existingParams = Array.isArray(device.params) ? [...device.params] : [];
+ const resolvedProductId = device.product_id || getParamValue(existingParams, DEVICE_PARAM_NAME.PRODUCT_ID);
+ const resolvedProductKey = device.product_key || getParamValue(existingParams, DEVICE_PARAM_NAME.PRODUCT_KEY);
+ const resolvedCloudIp = device.cloud_ip || getParamValue(existingParams, DEVICE_PARAM_NAME.CLOUD_IP);
+ const resolvedProtocolVersion =
+ protocolVersion || getParamValue(existingParams, DEVICE_PARAM_NAME.PROTOCOL_VERSION) || device.protocol_version;
+ const resolvedLocalKey = localKey || getParamValue(existingParams, DEVICE_PARAM_NAME.LOCAL_KEY) || device.local_key;
+ const resolvedIp = ip || getParamValue(existingParams, DEVICE_PARAM_NAME.IP_ADDRESS) || device.ip;
+
+ const hasFeatures = Array.isArray(device.features) && device.features.length > 0;
+ const hasDeviceMetadata = Boolean(device.properties || device.thing_model || device.specifications);
+ if (!hasFeatures && hasDeviceMetadata) {
+ const rebuiltDevice = convertDevice.call(tuyaManager, {
+ id: deviceId,
+ name: device.name,
+ product_name: device.model,
+ model: device.model,
+ product_id: resolvedProductId,
+ product_key: resolvedProductKey,
+ local_key: resolvedLocalKey,
+ ip: resolvedIp,
+ cloud_ip: resolvedCloudIp,
+ protocol_version: resolvedProtocolVersion,
+ local_override: true,
+ online: device.online,
+ properties: device.properties,
+ thing_model: device.thing_model,
+ specifications: device.specifications || {},
+ category: device.category,
+ tuya_report: device.tuya_report,
+ });
+
+ if (Array.isArray(rebuiltDevice.features) && rebuiltDevice.features.length > 0) {
+ device = {
+ ...device,
+ ...rebuiltDevice,
+ };
+ }
+ }
+
+ device.product_id = resolvedProductId;
+ device.product_key = resolvedProductKey;
+ device.protocol_version = resolvedProtocolVersion;
+ device.ip = resolvedIp;
+ device.local_override = true;
+ if (resolvedLocalKey) {
+ device.local_key = resolvedLocalKey;
+ }
+ device.params = Array.isArray(device.params) ? [...device.params] : [];
+ upsertParam(device.params, DEVICE_PARAM_NAME.IP_ADDRESS, resolvedIp);
+ upsertParam(device.params, DEVICE_PARAM_NAME.PROTOCOL_VERSION, resolvedProtocolVersion);
+ if (resolvedLocalKey) {
+ upsertParam(device.params, DEVICE_PARAM_NAME.LOCAL_KEY, resolvedLocalKey);
+ }
+ upsertParam(device.params, DEVICE_PARAM_NAME.LOCAL_OVERRIDE, true);
+ upsertParam(device.params, DEVICE_PARAM_NAME.PRODUCT_ID, resolvedProductId);
+ upsertParam(device.params, DEVICE_PARAM_NAME.PRODUCT_KEY, resolvedProductKey);
+
+ device = addFallbackBinaryFeature(device, dps);
+
+ if (tuyaManager.gladys && tuyaManager.gladys.stateManager) {
+ const existing = normalizeExistingDevice(
+ tuyaManager.gladys.stateManager.get('deviceByExternalId', device.external_id),
+ );
+ device = mergeDevices(device, existing);
+ }
+
+ tuyaManager.discoveredDevices[deviceIndex] = device;
+ return device;
+}
+
+module.exports = {
+ localPoll,
+ updateDiscoveredDeviceAfterLocalPoll,
+};
diff --git a/server/services/tuya/lib/tuya.localScan.js b/server/services/tuya/lib/tuya.localScan.js
new file mode 100644
index 0000000000..1fd11b4a68
--- /dev/null
+++ b/server/services/tuya/lib/tuya.localScan.js
@@ -0,0 +1,211 @@
+const dgram = require('dgram');
+const { UDP_KEY } = require('@demirdeniz/tuyapi-newgen/lib/config');
+const { MessageParser } = require('@demirdeniz/tuyapi-newgen/lib/message-parser');
+const logger = require('../../../utils/logger');
+const { mergeDevices } = require('../../../utils/device');
+const { convertDevice } = require('./device/tuya.convertDevice');
+const {
+ applyExistingLocalOverride,
+ normalizeExistingDevice,
+ updateDiscoveredDeviceWithLocalInfo,
+} = require('./utils/tuya.deviceParams');
+const { buildLocalScanReport } = require('./utils/tuya.report');
+
+const DEFAULT_PORTS = [6666, 6667, 7000];
+/**
+ * @description Scan local network for Tuya devices (UDP broadcast).
+ * @param {number|object} input - Scan duration in seconds or options.
+ * @returns {Promise} Map of deviceId -> { ip, version, productKey }.
+ * @example
+ * await localScan({ timeoutSeconds: 10 });
+ */
+async function localScan(input = 10) {
+ const options = typeof input === 'object' ? input || {} : { timeoutSeconds: input };
+ const parsedTimeout = Number(options.timeoutSeconds);
+ const timeoutSeconds = Number.isFinite(parsedTimeout) ? Math.min(Math.max(parsedTimeout, 1), 30) : 10;
+ const devices = {};
+ const portErrors = {};
+ const sockets = [];
+ const parsers = [
+ new MessageParser({ key: UDP_KEY, version: 3.1 }),
+ new MessageParser({ key: UDP_KEY, version: 3.5 }),
+ ];
+
+ logger.info(`[Tuya][localScan] Starting udp scan for ${timeoutSeconds}s on ports ${DEFAULT_PORTS.join(', ')}`);
+
+ const onMessage = (message, rinfo) => {
+ const byteLen = message ? message.length : 0;
+ const remote = rinfo ? `${rinfo.address}:${rinfo.port}` : 'unknown';
+ const source = rinfo && rinfo.source ? rinfo.source : 'udp';
+ logger.debug(`[Tuya][localScan] Packet received (${source}) from ${remote} len=${byteLen}`);
+ let parsed = null;
+ let lastError = null;
+ for (let i = 0; i < parsers.length; i += 1) {
+ try {
+ parsed = parsers[i].parse(message);
+ break;
+ } catch (e) {
+ lastError = e;
+ }
+ }
+ if (!parsed) {
+ logger.info(
+ `[Tuya][localScan] Unable to parse payload from ${remote} (len=${byteLen}): ${
+ lastError ? lastError.message : 'unknown'
+ }`,
+ );
+ return;
+ }
+ const safePayload =
+ parsed && parsed[0] && parsed[0].payload
+ ? {
+ gwId: parsed[0].payload.gwId,
+ devId: parsed[0].payload.devId,
+ id: parsed[0].payload.id,
+ version: parsed[0].payload.version,
+ hasIp: !!parsed[0].payload.ip,
+ }
+ : null;
+ logger.debug(`[Tuya][localScan] Parsed packet from ${remote}: ${JSON.stringify(safePayload)}`);
+ const payload = parsed && parsed[0] && parsed[0].payload;
+
+ if (!payload || typeof payload !== 'object') {
+ logger.info(`[Tuya][localScan] Ignoring payload from ${remote} (len=${byteLen}): invalid payload`);
+ return;
+ }
+
+ const { gwId, devId, id, ip, version, productKey } = payload;
+ const resolvedIp = ip || (rinfo && rinfo.address);
+ const deviceId = gwId || devId || id;
+
+ if (!deviceId) {
+ logger.info(`[Tuya][localScan] Ignoring payload from ${remote} (len=${byteLen}): missing deviceId`);
+ return;
+ }
+
+ const isNew = !devices[deviceId];
+ const previous = devices[deviceId] || {};
+ devices[deviceId] = {
+ ip: resolvedIp || previous.ip,
+ version: version || previous.version,
+ productKey: productKey || previous.productKey,
+ };
+ if (isNew) {
+ logger.info(`[Tuya][localScan] Found device ${deviceId} ip=${ip || 'unknown'} version=${version || 'unknown'}`);
+ }
+ };
+
+ DEFAULT_PORTS.forEach((port) => {
+ const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });
+ socket.on('message', onMessage);
+ socket.on('error', (err) => {
+ portErrors[port] = err && err.message ? err.message : 'unknown';
+ logger.info(`[Tuya][localScan] UDP socket error on port ${port}: ${err.message}`);
+ });
+ socket.on('listening', () => {
+ try {
+ const address = socket.address();
+ logger.info(`[Tuya][localScan] Listening on ${address.address}:${address.port}`);
+ } catch (e) {
+ logger.info(`[Tuya][localScan] Listening on port ${port}`);
+ }
+ });
+ socket.bind({ port, address: '0.0.0.0', exclusive: false });
+ sockets.push(socket);
+ });
+
+ await new Promise((resolve) => {
+ setTimeout(resolve, timeoutSeconds * 1000);
+ });
+
+ sockets.forEach((socket) => {
+ try {
+ socket.close();
+ } catch (e) {
+ // ignore
+ }
+ });
+
+ logger.info(`[Tuya][localScan] Scan complete. Found ${Object.keys(devices).length} device(s).`);
+ return { devices, portErrors };
+}
+
+/**
+ * @description Build local scan response and update discovered devices.
+ * @param {object} tuyaManager - Tuya handler instance.
+ * @param {object} localScanResult - Result of UDP scan.
+ * @returns {object} API response payload.
+ * @example
+ * buildLocalScanResponse(tuyaManager, { devices: {}, portErrors: {} });
+ */
+function buildLocalScanResponse(tuyaManager, localScanResult) {
+ const localDevicesById = (localScanResult && localScanResult.devices) || {};
+ const portErrors = (localScanResult && localScanResult.portErrors) || {};
+ const mergeWithExisting = (device) => {
+ if (!tuyaManager || !tuyaManager.gladys || !tuyaManager.gladys.stateManager) {
+ return device;
+ }
+ const existing = normalizeExistingDevice(
+ tuyaManager.gladys.stateManager.get('deviceByExternalId', device.external_id),
+ );
+ const withLocalOverride = applyExistingLocalOverride(device, existing);
+ return mergeDevices(withLocalOverride, existing);
+ };
+ const buildLocalDiscoveredDevice = (deviceId, localInfo) =>
+ convertDevice.call(tuyaManager, {
+ id: deviceId,
+ name: localInfo && localInfo.name ? localInfo.name : `Tuya ${deviceId}`,
+ product_key: localInfo && localInfo.productKey,
+ ip: localInfo && localInfo.ip,
+ protocol_version: localInfo && localInfo.version,
+ local_override: true,
+ specifications: {
+ functions: [],
+ status: [],
+ },
+ tuya_report: buildLocalScanReport(localInfo),
+ });
+
+ if (tuyaManager && Array.isArray(tuyaManager.discoveredDevices)) {
+ const updatedDevices = tuyaManager.discoveredDevices.map((device) => {
+ const deviceId = device.external_id && device.external_id.split(':')[1];
+ const localInfo = localDevicesById[deviceId];
+ return updateDiscoveredDeviceWithLocalInfo(device, localInfo);
+ });
+ const mergedDevices = updatedDevices.map((device) => mergeWithExisting(device));
+ const knownExternalIds = new Set(mergedDevices.map((device) => device.external_id));
+ const localOnlyDevices = Object.entries(localDevicesById)
+ .map(([deviceId, localInfo]) => buildLocalDiscoveredDevice(deviceId, localInfo))
+ .filter((device) => !knownExternalIds.has(device.external_id))
+ .map((device) => mergeWithExisting(device));
+ const allDiscoveredDevices = [...mergedDevices, ...localOnlyDevices];
+ tuyaManager.discoveredDevices = allDiscoveredDevices;
+ return {
+ devices: allDiscoveredDevices,
+ local_devices: localDevicesById,
+ port_errors: portErrors,
+ };
+ }
+
+ if (tuyaManager && Object.keys(localDevicesById).length > 0) {
+ const localDiscoveredDevices = Object.entries(localDevicesById)
+ .map(([deviceId, localInfo]) => buildLocalDiscoveredDevice(deviceId, localInfo))
+ .map((device) => mergeWithExisting(device));
+ tuyaManager.discoveredDevices = localDiscoveredDevices;
+ return {
+ devices: localDiscoveredDevices,
+ local_devices: localDevicesById,
+ port_errors: portErrors,
+ };
+ }
+
+ return {
+ local_devices: localDevicesById,
+ port_errors: portErrors,
+ };
+}
+
+module.exports = {
+ localScan,
+ buildLocalScanResponse,
+};
diff --git a/server/services/tuya/lib/tuya.manualDisconnect.js b/server/services/tuya/lib/tuya.manualDisconnect.js
new file mode 100644
index 0000000000..732f1f877e
--- /dev/null
+++ b/server/services/tuya/lib/tuya.manualDisconnect.js
@@ -0,0 +1,15 @@
+const { GLADYS_VARIABLES } = require('./utils/tuya.constants');
+
+/**
+ * @description Manually disconnect from Tuya cloud and disable auto-reconnect.
+ * @example
+ * await manualDisconnect();
+ */
+async function manualDisconnect() {
+ await this.gladys.variable.setValue(GLADYS_VARIABLES.MANUAL_DISCONNECT, 'true', this.serviceId);
+ await this.disconnect({ manual: true });
+}
+
+module.exports = {
+ manualDisconnect,
+};
diff --git a/server/services/tuya/lib/tuya.poll.js b/server/services/tuya/lib/tuya.poll.js
index a3d084201e..4a75345d38 100644
--- a/server/services/tuya/lib/tuya.poll.js
+++ b/server/services/tuya/lib/tuya.poll.js
@@ -1,7 +1,190 @@
const { BadParameters } = require('../../../utils/coreErrors');
+const logger = require('../../../utils/logger');
const { readValues } = require('./device/tuya.deviceMapping');
-const { API } = require('./utils/tuya.constants');
+const { API, DEVICE_PARAM_NAME } = require('./utils/tuya.constants');
const { EVENTS } = require('../../../utils/constants');
+const { CLOUD_STRATEGY, getConfiguredCloudReadStrategy } = require('./utils/tuya.cloudStrategy');
+const { normalizeBoolean } = require('./utils/tuya.normalize');
+const { getParamValue } = require('./utils/tuya.deviceParams');
+const { localPoll } = require('./tuya.localPoll');
+const { getLocalDpsFromCode } = require('./device/tuya.localMapping');
+
+const SAME_VALUE_EMIT_INTERVAL_MS = 3 * 60 * 1000;
+
+const getFeatureCode = (deviceFeature) => {
+ if (!deviceFeature || !deviceFeature.external_id) {
+ return null;
+ }
+ const parts = String(deviceFeature.external_id).split(':');
+ if (parts.length >= 2) {
+ return parts[parts.length - 1] || null;
+ }
+ return null;
+};
+
+const getFeatureReader = (deviceFeature) => {
+ if (!deviceFeature || !deviceFeature.category || !deviceFeature.type) {
+ return null;
+ }
+ const categoryReaders = readValues[deviceFeature.category];
+ if (!categoryReaders) {
+ return null;
+ }
+ return categoryReaders[deviceFeature.type] || null;
+};
+
+const hasDpsKey = (dps, key) => {
+ const stringKey = String(key);
+ return Object.prototype.hasOwnProperty.call(dps, stringKey) || Object.prototype.hasOwnProperty.call(dps, key);
+};
+
+const getCurrentFeatureState = (gladys, deviceFeature) => {
+ const selector = deviceFeature && deviceFeature.selector;
+ if (selector && gladys && gladys.stateManager && typeof gladys.stateManager.get === 'function') {
+ const currentFeature = gladys.stateManager.get('deviceFeature', selector);
+ if (currentFeature) {
+ return {
+ lastValue: Object.prototype.hasOwnProperty.call(currentFeature, 'last_value')
+ ? currentFeature.last_value
+ : deviceFeature && deviceFeature.last_value,
+ lastValueChanged: Object.prototype.hasOwnProperty.call(currentFeature, 'last_value_changed')
+ ? currentFeature.last_value_changed
+ : deviceFeature && deviceFeature.last_value_changed,
+ };
+ }
+ }
+ return {
+ lastValue: deviceFeature ? deviceFeature.last_value : undefined,
+ lastValueChanged: deviceFeature ? deviceFeature.last_value_changed : undefined,
+ };
+};
+
+const toTimestamp = (value) => {
+ if (value === undefined || value === null) {
+ return null;
+ }
+ const date = value instanceof Date ? value : new Date(value);
+ const timestamp = date.getTime();
+ if (Number.isNaN(timestamp)) {
+ return null;
+ }
+ return timestamp;
+};
+
+const emitFeatureState = (gladys, deviceFeature, transformedValue, previousValue, previousValueChangedAt) => {
+ if (transformedValue === null || transformedValue === undefined) {
+ return { emitted: false, changed: false };
+ }
+
+ const changed = previousValue !== transformedValue;
+ let emitted = changed;
+
+ if (!emitted) {
+ const lastValueChangedTs = toTimestamp(previousValueChangedAt);
+ const now = Date.now();
+ if (lastValueChangedTs === null || now - lastValueChangedTs >= SAME_VALUE_EMIT_INTERVAL_MS) {
+ emitted = true;
+ }
+ }
+
+ if (emitted) {
+ gladys.event.emit(EVENTS.DEVICE.NEW_STATE, {
+ device_feature_external_id: deviceFeature.external_id,
+ state: transformedValue,
+ });
+ }
+
+ return { emitted, changed };
+};
+
+const extractValuesFromResultArray = (result) => {
+ const values = {};
+ const entries = Array.isArray(result) ? result : [];
+ entries.forEach((feature) => {
+ if (!feature || typeof feature !== 'object' || feature.code === undefined || feature.code === null) {
+ return;
+ }
+ values[String(feature.code)] = feature.value;
+ });
+ return values;
+};
+
+const extractShadowValues = (response) => {
+ const payload = response && response.result;
+ const properties = payload && Array.isArray(payload.properties) ? payload.properties : [];
+ return extractValuesFromResultArray(properties);
+};
+
+const pollCloudFeatures = async function pollCloudFeatures(device, deviceFeatures, topic) {
+ const summary = {
+ polled: Array.isArray(deviceFeatures) ? deviceFeatures.length : 0,
+ handled: 0,
+ changed: 0,
+ missing: 0,
+ skipped: 0,
+ };
+ if (!Array.isArray(deviceFeatures) || deviceFeatures.length === 0) {
+ return summary;
+ }
+
+ if (!this.connector || typeof this.connector.request !== 'function') {
+ logger.warn(`[Tuya][poll][cloud] connector unavailable for device=${topic}`);
+ return summary;
+ }
+
+ const cloudReadStrategy = getConfiguredCloudReadStrategy(device);
+ const response =
+ cloudReadStrategy === CLOUD_STRATEGY.SHADOW
+ ? await this.connector.request({
+ method: 'GET',
+ path: `${API.VERSION_2_0}/thing/${topic}/shadow/properties`,
+ })
+ : await this.connector.request({
+ method: 'GET',
+ path: `${API.VERSION_1_0}/devices/${topic}/status`,
+ });
+
+ const values =
+ cloudReadStrategy === CLOUD_STRATEGY.SHADOW
+ ? extractShadowValues(response)
+ : extractValuesFromResultArray(response && response.result);
+
+ deviceFeatures.forEach((deviceFeature) => {
+ const code = getFeatureCode(deviceFeature);
+ if (!code) {
+ summary.skipped += 1;
+ return;
+ }
+
+ const reader = getFeatureReader(deviceFeature);
+ if (!reader) {
+ summary.skipped += 1;
+ return;
+ }
+
+ const value = values[code];
+ if (value === undefined) {
+ summary.missing += 1;
+ return;
+ }
+ let transformedValue;
+ try {
+ transformedValue = reader(value, deviceFeature);
+ } catch (e) {
+ summary.skipped += 1;
+ logger.warn(`[Tuya][poll][cloud] reader failed for device=${topic} code=${code}`, e);
+ return;
+ }
+ const { lastValue, lastValueChanged } = getCurrentFeatureState(this.gladys, deviceFeature);
+ const { changed } = emitFeatureState(this.gladys, deviceFeature, transformedValue, lastValue, lastValueChanged);
+ if (changed) {
+ summary.changed += 1;
+ }
+ summary.handled += 1;
+ });
+
+ return summary;
+};
/**
*
@@ -22,31 +205,135 @@ async function poll(device) {
throw new BadParameters(`Tuya device external_id is invalid: "${externalId}" have no network indicator`);
}
- const response = await this.connector.request({
- method: 'GET',
- path: `${API.VERSION_1_0}/devices/${topic}/status`,
- });
+ const params = device.params || [];
+ const deviceFeatures = Array.isArray(device.features) ? device.features : [];
+ const ipAddress = getParamValue(params, DEVICE_PARAM_NAME.IP_ADDRESS);
+ const localKey = getParamValue(params, DEVICE_PARAM_NAME.LOCAL_KEY);
+ const protocolVersionRaw = getParamValue(params, DEVICE_PARAM_NAME.PROTOCOL_VERSION);
+ const protocolVersion =
+ protocolVersionRaw !== null && protocolVersionRaw !== undefined ? String(protocolVersionRaw).trim() : undefined;
+ const localOverride = normalizeBoolean(getParamValue(params, DEVICE_PARAM_NAME.LOCAL_OVERRIDE));
+ const hasLocalConfig = Boolean(ipAddress && localKey && protocolVersion && localOverride === true);
+ const requestedMode = localOverride === true ? 'local' : 'cloud';
+ logger.debug(
+ `[Tuya][poll] device=${topic} requested=${requestedMode} has_local=${Boolean(
+ hasLocalConfig,
+ )} protocol=${protocolVersion || 'none'} ip=${ipAddress || 'none'}`,
+ );
- const values = {};
- (response.result || []).forEach((feature) => {
- values[feature.code] = feature.value;
- });
+ let modeUsed = 'cloud';
+ let localHandled = 0;
+ let localChanged = 0;
+ let cloudSummary = {
+ polled: 0,
+ handled: 0,
+ changed: 0,
+ missing: 0,
+ skipped: 0,
+ };
+ let fallbackReason = 'none';
- device.features.forEach((deviceFeature) => {
- const [, , code] = deviceFeature.external_id.split(':');
+ if (localOverride === true && !hasLocalConfig) {
+ fallbackReason = 'incomplete_local_config';
+ logger.warn(
+ `[Tuya][poll] local mode enabled but config is incomplete for device=${topic} (ip/protocol/local_key missing)`,
+ );
+ }
- const value = values[code];
- const transformedValue = readValues[deviceFeature.category][deviceFeature.type](value);
+ if (hasLocalConfig) {
+ try {
+ const localResult = await localPoll({
+ deviceId: topic,
+ ip: ipAddress,
+ localKey,
+ protocolVersion,
+ timeoutMs: 3000,
+ fastScan: true,
+ logDps: false,
+ });
+
+ const dps = localResult && localResult.dps ? localResult.dps : null;
+ if (dps && typeof dps === 'object') {
+ const pendingCloudFeatures = [];
+
+ deviceFeatures.forEach((deviceFeature) => {
+ const code = getFeatureCode(deviceFeature);
+ const dpsKey = getLocalDpsFromCode(code, device);
+ const reader = getFeatureReader(deviceFeature);
- if (deviceFeature.last_value !== transformedValue) {
- if (transformedValue !== null && transformedValue !== undefined) {
- this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, {
- device_feature_external_id: deviceFeature.external_id,
- state: transformedValue,
+ if (!code || dpsKey === null || !reader || !hasDpsKey(dps, dpsKey)) {
+ pendingCloudFeatures.push(deviceFeature);
+ return;
+ }
+
+ const rawValue = Object.prototype.hasOwnProperty.call(dps, String(dpsKey))
+ ? dps[String(dpsKey)]
+ : dps[dpsKey];
+ if (rawValue === undefined) {
+ pendingCloudFeatures.push(deviceFeature);
+ return;
+ }
+ let transformedValue;
+ try {
+ transformedValue = reader(rawValue, deviceFeature);
+ } catch (e) {
+ pendingCloudFeatures.push(deviceFeature);
+ logger.warn(`[Tuya][poll] local reader failed for device=${topic} code=${code}; falling back to cloud`, e);
+ return;
+ }
+ const { lastValue, lastValueChanged } = getCurrentFeatureState(this.gladys, deviceFeature);
+ const { changed } = emitFeatureState(
+ this.gladys,
+ deviceFeature,
+ transformedValue,
+ lastValue,
+ lastValueChanged,
+ );
+ if (changed) {
+ localChanged += 1;
+ }
+ localHandled += 1;
});
+
+ if (pendingCloudFeatures.length === 0) {
+ modeUsed = 'local';
+ logger.debug(
+ `[Tuya][poll] device=${topic} mode=${modeUsed} local_handled=${localHandled} local_changed=${localChanged} cloud_handled=0 cloud_changed=0 cloud_missing=0 fallback=${fallbackReason}`,
+ );
+ return;
+ }
+
+ fallbackReason = 'partial_local_mapping';
+ try {
+ cloudSummary = await pollCloudFeatures.call(this, device, pendingCloudFeatures, topic);
+ } catch (e) {
+ logger.warn(`[Tuya][poll] local poll succeeded but cloud fallback failed for ${topic}`, e);
+ fallbackReason = 'cloud_fallback_failed';
+ }
+ modeUsed = 'local+cloud';
+ logger.debug(
+ `[Tuya][poll] device=${topic} mode=${modeUsed} local_handled=${localHandled} local_changed=${localChanged} cloud_handled=${cloudSummary.handled} cloud_changed=${cloudSummary.changed} cloud_missing=${cloudSummary.missing} fallback=${fallbackReason}`,
+ );
+ return;
}
+
+ fallbackReason = 'invalid_local_payload';
+ logger.warn(`[Tuya][poll] local poll returned invalid DPS payload for ${topic}, falling back to cloud`);
+ } catch (e) {
+ logger.warn(`[Tuya][poll] local poll failed for ${topic}, falling back to cloud`, e);
+ fallbackReason = 'local_poll_failed';
}
- });
+ }
+
+ try {
+ cloudSummary = await pollCloudFeatures.call(this, device, deviceFeatures, topic);
+ } catch (e) {
+ logger.warn(`[Tuya][poll] cloud poll failed for ${topic}`, e);
+ fallbackReason = fallbackReason === 'none' ? 'cloud_poll_failed' : `${fallbackReason}+cloud_poll_failed`;
+ }
+ logger.debug(
+ `[Tuya][poll] device=${topic} mode=${modeUsed} local_handled=${localHandled} local_changed=${localChanged} cloud_handled=${cloudSummary.handled} cloud_changed=${cloudSummary.changed} cloud_missing=${cloudSummary.missing} fallback=${fallbackReason}`,
+ );
}
module.exports = {
diff --git a/server/services/tuya/lib/tuya.saveConfiguration.js b/server/services/tuya/lib/tuya.saveConfiguration.js
index 5d72ac2fe7..a4c0db5a72 100644
--- a/server/services/tuya/lib/tuya.saveConfiguration.js
+++ b/server/services/tuya/lib/tuya.saveConfiguration.js
@@ -11,11 +11,14 @@ const { GLADYS_VARIABLES } = require('./utils/tuya.constants');
*/
async function saveConfiguration(configuration) {
logger.debug('Saving Tuya configuration...');
- const { endpoint, accessKey, secretKey, appAccountId } = configuration;
+ const { endpoint, accessKey, secretKey, appAccountId, appUsername } = configuration;
await this.gladys.variable.setValue(GLADYS_VARIABLES.ENDPOINT, endpoint, this.serviceId);
await this.gladys.variable.setValue(GLADYS_VARIABLES.ACCESS_KEY, accessKey, this.serviceId);
await this.gladys.variable.setValue(GLADYS_VARIABLES.SECRET_KEY, secretKey, this.serviceId);
await this.gladys.variable.setValue(GLADYS_VARIABLES.APP_ACCOUNT_UID, appAccountId, this.serviceId);
+ await this.gladys.variable.setValue(GLADYS_VARIABLES.APP_USERNAME, appUsername, this.serviceId);
+ await this.gladys.variable.setValue(GLADYS_VARIABLES.MANUAL_DISCONNECT, 'false', this.serviceId);
+ await this.gladys.variable.setValue(GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, '', this.serviceId);
return configuration;
}
diff --git a/server/services/tuya/lib/tuya.setValue.js b/server/services/tuya/lib/tuya.setValue.js
index b2d6083e7a..a47b242645 100644
--- a/server/services/tuya/lib/tuya.setValue.js
+++ b/server/services/tuya/lib/tuya.setValue.js
@@ -1,7 +1,13 @@
+const TuyAPI = require('tuyapi');
+const TuyAPINewGen = require('@demirdeniz/tuyapi-newgen');
const logger = require('../../../utils/logger');
const { API } = require('./utils/tuya.constants');
const { BadParameters } = require('../../../utils/coreErrors');
const { writeValues } = require('./device/tuya.deviceMapping');
+const { DEVICE_PARAM_NAME } = require('./utils/tuya.constants');
+const { normalizeBoolean } = require('./utils/tuya.normalize');
+const { getParamValue } = require('./utils/tuya.deviceParams');
+const { getLocalDpsFromCode } = require('./device/tuya.localMapping');
/**
* @description Send the new device value over device protocol.
@@ -21,10 +27,71 @@ async function setValue(device, deviceFeature, value) {
if (!topic || topic.length === 0) {
throw new BadParameters(`Tuya device external_id is invalid: "${externalId}" have no network indicator`);
}
+ if (!command || command.trim().length === 0) {
+ throw new BadParameters(`Tuya device external_id is invalid: "${externalId}" have no command`);
+ }
- const transformedValue = writeValues[deviceFeature.category][deviceFeature.type](value);
+ const writeCategory = writeValues[deviceFeature.category];
+ const writeFn = writeCategory ? writeCategory[deviceFeature.type] : null;
+ const transformedValue = writeFn ? writeFn(value) : value;
logger.debug(`Change value for devices ${topic}/${command} to value ${transformedValue}...`);
+ const params = device.params || [];
+ const ipAddress = getParamValue(params, DEVICE_PARAM_NAME.IP_ADDRESS);
+ const localKey = getParamValue(params, DEVICE_PARAM_NAME.LOCAL_KEY);
+ const protocolVersionRaw = getParamValue(params, DEVICE_PARAM_NAME.PROTOCOL_VERSION);
+ const protocolVersion =
+ protocolVersionRaw !== null && protocolVersionRaw !== undefined ? String(protocolVersionRaw).trim() : undefined;
+ const localOverride = normalizeBoolean(getParamValue(params, DEVICE_PARAM_NAME.LOCAL_OVERRIDE));
+
+ const hasLocalConfig = ipAddress && localKey && protocolVersion && localOverride === true;
+
+ const localDps = getLocalDpsFromCode(command, device);
+
+ if (hasLocalConfig && localDps !== null) {
+ const isProtocol35 = protocolVersion === '3.5';
+ const TuyaLocalApi = isProtocol35 ? TuyAPINewGen : TuyAPI;
+ const tuyaOptions = {
+ id: topic,
+ key: localKey,
+ ip: ipAddress,
+ version: protocolVersion,
+ issueGetOnConnect: false,
+ issueRefreshOnConnect: false,
+ issueRefreshOnPing: false,
+ };
+ if (isProtocol35) {
+ tuyaOptions.keepAlive = false;
+ }
+ const runLocalSet = async () => {
+ const tuyaLocal = new TuyaLocalApi(tuyaOptions);
+ let connected = false;
+ try {
+ await tuyaLocal.connect();
+ connected = true;
+ await tuyaLocal.set({ dps: localDps, set: transformedValue });
+ logger.debug(`[Tuya][setValue][local] device=${topic} dps=${localDps} value=${transformedValue}`);
+ return true;
+ } catch (e) {
+ logger.warn(`[Tuya][setValue][local] failed, fallback to cloud`, e);
+ return false;
+ } finally {
+ if (connected) {
+ try {
+ await tuyaLocal.disconnect();
+ } catch (disconnectError) {
+ logger.warn('[Tuya][setValue][local] disconnect failed', disconnectError);
+ }
+ }
+ }
+ };
+
+ const localSuccess = await runLocalSet();
+ if (localSuccess) {
+ return;
+ }
+ }
+
const response = await this.connector.request({
method: 'POST',
path: `${API.VERSION_1_0}/devices/${topic}/commands`,
diff --git a/server/services/tuya/lib/utils/tuya.cloudStrategy.js b/server/services/tuya/lib/utils/tuya.cloudStrategy.js
new file mode 100644
index 0000000000..97ac1f4671
--- /dev/null
+++ b/server/services/tuya/lib/utils/tuya.cloudStrategy.js
@@ -0,0 +1,55 @@
+const { DEVICE_PARAM_NAME } = require('./tuya.constants');
+const { getParamValue } = require('./tuya.deviceParams');
+const { getFeatureMapping, getIgnoredCloudCodes, normalizeCode } = require('../mappings');
+
+const CLOUD_STRATEGY = {
+ LEGACY: 'legacy',
+ SHADOW: 'shadow',
+};
+
+const isSupportedCloudCode = (code, deviceType, ignoredCloudCodes) => {
+ const normalizedCode = normalizeCode(code);
+ if (!normalizedCode) {
+ return false;
+ }
+ if (ignoredCloudCodes.includes(normalizedCode)) {
+ return false;
+ }
+ return Boolean(getFeatureMapping(normalizedCode, deviceType));
+};
+
+const getThingModelProperties = (device) => {
+ if (!device || !device.thing_model || !Array.isArray(device.thing_model.services)) {
+ return [];
+ }
+ return device.thing_model.services.flatMap((service) =>
+ Array.isArray(service && service.properties) ? service.properties : [],
+ );
+};
+
+const resolveCloudReadStrategy = (device, deviceType) => {
+ const ignoredCloudCodes = getIgnoredCloudCodes(deviceType);
+ const status = Array.isArray(device && device.specifications && device.specifications.status)
+ ? device.specifications.status
+ : [];
+ if (status.some((entry) => isSupportedCloudCode(entry && entry.code, deviceType, ignoredCloudCodes))) {
+ return CLOUD_STRATEGY.LEGACY;
+ }
+ const thingProperties = getThingModelProperties(device);
+ if (thingProperties.some((entry) => isSupportedCloudCode(entry && entry.code, deviceType, ignoredCloudCodes))) {
+ return CLOUD_STRATEGY.SHADOW;
+ }
+ return null;
+};
+
+const normalizeCloudStrategy = (value) =>
+ value === CLOUD_STRATEGY.SHADOW ? CLOUD_STRATEGY.SHADOW : CLOUD_STRATEGY.LEGACY;
+
+const getConfiguredCloudReadStrategy = (device) =>
+ normalizeCloudStrategy(getParamValue(device && device.params, DEVICE_PARAM_NAME.CLOUD_READ_STRATEGY));
+
+module.exports = {
+ CLOUD_STRATEGY,
+ getConfiguredCloudReadStrategy,
+ resolveCloudReadStrategy,
+};
diff --git a/server/services/tuya/lib/utils/tuya.config.js b/server/services/tuya/lib/utils/tuya.config.js
new file mode 100644
index 0000000000..f309a294d1
--- /dev/null
+++ b/server/services/tuya/lib/utils/tuya.config.js
@@ -0,0 +1,25 @@
+const crypto = require('crypto');
+
+/**
+ * @description Build a stable hash for the Tuya configuration.
+ * @param {object} config - Tuya configuration.
+ * @returns {string} SHA-256 hash.
+ * @example
+ * const hash = buildConfigHash({ endpoint: 'eu', accessKey: 'key', secretKey: 'secret', appAccountId: 'uid' });
+ */
+const buildConfigHash = (config = {}) => {
+ const payload = JSON.stringify({
+ endpoint: config.endpoint || '',
+ accessKey: config.accessKey || '',
+ secretKey: config.secretKey || '',
+ appAccountId: config.appAccountId || '',
+ });
+ return crypto
+ .createHash('sha256')
+ .update(payload)
+ .digest('hex');
+};
+
+module.exports = {
+ buildConfigHash,
+};
diff --git a/server/services/tuya/lib/utils/tuya.constants.js b/server/services/tuya/lib/utils/tuya.constants.js
index a4e0188b94..478599fbe7 100644
--- a/server/services/tuya/lib/utils/tuya.constants.js
+++ b/server/services/tuya/lib/utils/tuya.constants.js
@@ -5,6 +5,9 @@ const GLADYS_VARIABLES = {
ACCESS_TOKEN: 'TUYA_ACCESS_TOKEN',
REFRESH_TOKEN: 'TUYA_REFRESH_TOKEN',
APP_ACCOUNT_UID: 'TUYA_APP_ACCOUNT_UID',
+ APP_USERNAME: 'TUYA_APP_USERNAME',
+ MANUAL_DISCONNECT: 'TUYA_MANUAL_DISCONNECT',
+ LAST_CONNECTED_CONFIG_HASH: 'TUYA_LAST_CONNECTED_CONFIG_HASH',
};
const TUYA_ENDPOINTS = {
@@ -25,10 +28,24 @@ const STATUS = {
};
const API = {
+ PUBLIC_VERSION_1_0: '/v1.0',
VERSION_1_0: '/v1.0/iot-03',
VERSION_1_1: '/v1.1/iot-03',
VERSION_1_2: '/v1.2/iot-03',
VERSION_1_3: '/v1.3/iot-03',
+ VERSION_2_0: '/v2.0/cloud',
+};
+
+const DEVICE_PARAM_NAME = {
+ DEVICE_ID: 'DEVICE_ID',
+ LOCAL_KEY: 'LOCAL_KEY',
+ IP_ADDRESS: 'IP_ADDRESS',
+ PROTOCOL_VERSION: 'PROTOCOL_VERSION',
+ CLOUD_IP: 'CLOUD_IP',
+ CLOUD_READ_STRATEGY: 'CLOUD_READ_STRATEGY',
+ LOCAL_OVERRIDE: 'LOCAL_OVERRIDE',
+ PRODUCT_ID: 'PRODUCT_ID',
+ PRODUCT_KEY: 'PRODUCT_KEY',
};
module.exports = {
@@ -36,4 +53,5 @@ module.exports = {
TUYA_ENDPOINTS,
STATUS,
API,
+ DEVICE_PARAM_NAME,
};
diff --git a/server/services/tuya/lib/utils/tuya.deviceParams.js b/server/services/tuya/lib/utils/tuya.deviceParams.js
new file mode 100644
index 0000000000..b09c1185e5
--- /dev/null
+++ b/server/services/tuya/lib/utils/tuya.deviceParams.js
@@ -0,0 +1,112 @@
+const { DEVICE_PARAM_NAME } = require('./tuya.constants');
+const { normalizeBoolean } = require('./tuya.normalize');
+const { buildLocalScanReport, withTuyaReport } = require('./tuya.report');
+
+const upsertParam = (params, name, value) => {
+ if (value === undefined || value === null) {
+ return;
+ }
+ const index = params.findIndex((param) => param.name === name);
+ if (index >= 0) {
+ params[index] = { ...params[index], value };
+ } else {
+ params.push({ name, value });
+ }
+};
+
+const getParamValue = (params, name) => {
+ if (!Array.isArray(params)) {
+ return undefined;
+ }
+ const found = params.find((param) => param.name === name);
+ return found ? found.value : undefined;
+};
+
+const normalizeExistingDevice = (device) => {
+ if (!device || !Array.isArray(device.params)) {
+ return device;
+ }
+ const normalizedParams = device.params.map((param) => {
+ if (param.name !== DEVICE_PARAM_NAME.LOCAL_OVERRIDE) {
+ return param;
+ }
+ if (param.value === undefined || param.value === null) {
+ return param;
+ }
+ return { ...param, value: normalizeBoolean(param.value) };
+ });
+ return { ...device, params: normalizedParams };
+};
+
+const updateDiscoveredDeviceWithLocalInfo = (device, localInfo) => {
+ if (!device || !localInfo) {
+ return device;
+ }
+ const updated = { ...device };
+ if (localInfo.version !== undefined && localInfo.version !== null) {
+ updated.protocol_version = localInfo.version;
+ }
+ updated.ip = localInfo.ip || updated.ip;
+ if (localInfo.productKey !== undefined && localInfo.productKey !== null) {
+ updated.product_key = localInfo.productKey;
+ }
+ updated.params = Array.isArray(updated.params) ? [...updated.params] : [];
+ upsertParam(updated.params, DEVICE_PARAM_NAME.IP_ADDRESS, updated.ip);
+ upsertParam(updated.params, DEVICE_PARAM_NAME.PROTOCOL_VERSION, updated.protocol_version);
+ upsertParam(updated.params, DEVICE_PARAM_NAME.PRODUCT_KEY, updated.product_key);
+ return withTuyaReport(updated, buildLocalScanReport(localInfo));
+};
+
+const applyExistingLocalParams = (device, existingDevice) => {
+ if (!existingDevice) {
+ return device;
+ }
+ const params = Array.isArray(device.params) ? [...device.params] : [];
+ const ipValue = getParamValue(existingDevice.params, DEVICE_PARAM_NAME.IP_ADDRESS);
+ const protocolValue = getParamValue(existingDevice.params, DEVICE_PARAM_NAME.PROTOCOL_VERSION);
+ const rawLocalOverrideValue = getParamValue(existingDevice.params, DEVICE_PARAM_NAME.LOCAL_OVERRIDE);
+ const localOverrideValue =
+ rawLocalOverrideValue !== undefined && rawLocalOverrideValue !== null
+ ? normalizeBoolean(rawLocalOverrideValue)
+ : rawLocalOverrideValue;
+
+ upsertParam(params, DEVICE_PARAM_NAME.IP_ADDRESS, ipValue);
+ upsertParam(params, DEVICE_PARAM_NAME.PROTOCOL_VERSION, protocolValue);
+ upsertParam(params, DEVICE_PARAM_NAME.LOCAL_OVERRIDE, localOverrideValue);
+
+ const resolvedLocalOverride =
+ localOverrideValue !== undefined && localOverrideValue !== null ? localOverrideValue : device.local_override;
+
+ return {
+ ...device,
+ params,
+ ip: ipValue !== undefined && ipValue !== null ? ipValue : device.ip,
+ protocol_version: protocolValue !== undefined && protocolValue !== null ? protocolValue : device.protocol_version,
+ local_override: resolvedLocalOverride,
+ };
+};
+
+const applyExistingLocalOverride = (device, existingDevice) => {
+ if (!existingDevice || !Array.isArray(existingDevice.params)) {
+ return device;
+ }
+ const overrideParam = existingDevice.params.find((param) => param.name === DEVICE_PARAM_NAME.LOCAL_OVERRIDE);
+ if (!overrideParam || overrideParam.value === undefined || overrideParam.value === null) {
+ return device;
+ }
+ const updated = { ...device };
+ updated.params = Array.isArray(updated.params) ? [...updated.params] : [];
+ const normalizedOverride = normalizeBoolean(overrideParam.value);
+ upsertParam(updated.params, DEVICE_PARAM_NAME.LOCAL_OVERRIDE, normalizedOverride);
+ updated.local_override = normalizedOverride;
+ return updated;
+};
+
+module.exports = {
+ applyExistingLocalOverride,
+ applyExistingLocalParams,
+ getParamValue,
+ normalizeExistingDevice,
+ updateDiscoveredDeviceWithLocalInfo,
+ upsertParam,
+};
diff --git a/server/services/tuya/lib/utils/tuya.normalize.js b/server/services/tuya/lib/utils/tuya.normalize.js
new file mode 100644
index 0000000000..3bcd035346
--- /dev/null
+++ b/server/services/tuya/lib/utils/tuya.normalize.js
@@ -0,0 +1,10 @@
+const normalizeBoolean = (value) => {
+ if (value === true || value === 1 || value === '1') {
+ return true;
+ }
+ return typeof value === 'string' && ['true', 'on'].includes(value.trim().toLowerCase());
+};
+
+module.exports = {
+ normalizeBoolean,
+};
diff --git a/server/services/tuya/lib/utils/tuya.report.js b/server/services/tuya/lib/utils/tuya.report.js
new file mode 100644
index 0000000000..4a8b82b2fc
--- /dev/null
+++ b/server/services/tuya/lib/utils/tuya.report.js
@@ -0,0 +1,162 @@
+const { API } = require('./tuya.constants');
+
+const REPORT_SCHEMA_VERSION = 2;
+
+const buildRequest = (method, path) => ({
+ method,
+ path,
+});
+
+const normalizeSettledResponse = (request, settledResult) => {
+ if (!settledResult) {
+ return null;
+ }
+ if (settledResult.status === 'fulfilled') {
+ return {
+ request,
+ response: settledResult.value || null,
+ error: null,
+ };
+ }
+ const error =
+ settledResult.reason && settledResult.reason.message ? settledResult.reason.message : settledResult.reason || null;
+ return {
+ request,
+ response: null,
+ error,
+ };
+};
+
+const createBaseReport = (currentReportInput = {}) => {
+ const currentReport = currentReportInput || {};
+ return {
+ schema_version: currentReport.schema_version || REPORT_SCHEMA_VERSION,
+ cloud: {
+ assembled: {
+ specifications:
+ currentReport.cloud && currentReport.cloud.assembled
+ ? currentReport.cloud.assembled.specifications || null
+ : null,
+ properties:
+ currentReport.cloud && currentReport.cloud.assembled
+ ? currentReport.cloud.assembled.properties || null
+ : null,
+ thing_model:
+ currentReport.cloud && currentReport.cloud.assembled
+ ? currentReport.cloud.assembled.thing_model || null
+ : null,
+ },
+ raw: {
+ device_list_entry:
+ currentReport.cloud && currentReport.cloud.raw ? currentReport.cloud.raw.device_list_entry || null : null,
+ device_specification:
+ currentReport.cloud && currentReport.cloud.raw ? currentReport.cloud.raw.device_specification || null : null,
+ device_details:
+ currentReport.cloud && currentReport.cloud.raw ? currentReport.cloud.raw.device_details || null : null,
+ thing_shadow_properties:
+ currentReport.cloud && currentReport.cloud.raw
+ ? currentReport.cloud.raw.thing_shadow_properties || null
+ : null,
+ thing_model:
+ currentReport.cloud && currentReport.cloud.raw ? currentReport.cloud.raw.thing_model || null : null,
+ },
+ },
+ local: {
+ scan: currentReport.local ? currentReport.local.scan || null : null,
+ },
+ };
+};
+
+const mergeTuyaReport = (currentReport, reportPatch) => {
+ const base = createBaseReport(currentReport);
+ if (!reportPatch) {
+ return base;
+ }
+ return {
+ schema_version: reportPatch.schema_version || base.schema_version,
+ cloud: {
+ assembled: {
+ ...base.cloud.assembled,
+ ...(reportPatch.cloud && reportPatch.cloud.assembled ? reportPatch.cloud.assembled : {}),
+ },
+ raw: {
+ ...base.cloud.raw,
+ ...(reportPatch.cloud && reportPatch.cloud.raw ? reportPatch.cloud.raw : {}),
+ },
+ },
+ local: {
+ ...base.local,
+ ...(reportPatch.local || {}),
+ },
+ };
+};
+
+const withTuyaReport = (device, reportPatch) => {
+ if (!device) {
+ return device;
+ }
+ return {
+ ...device,
+ tuya_report: mergeTuyaReport(device.tuya_report, reportPatch),
+ };
+};
+
+const buildDeviceListEntryReport = (deviceListEntry) => {
+ if (!deviceListEntry) {
+ return null;
+ }
+ return {
+ request: buildRequest('GET', `${API.PUBLIC_VERSION_1_0}/users/{sourceId}/devices`),
+ response_item: deviceListEntry,
+ };
+};
+
+const buildLocalScanReport = (localInfo) => ({
+ local: {
+ scan: localInfo
+ ? {
+ source: 'udp',
+ response: localInfo,
+ }
+ : null,
+ },
+});
+
+const buildCloudReport = ({ deviceId, listDeviceEntry, specResult, detailsResult, propsResult, modelResult, device }) =>
+ mergeTuyaReport(null, {
+ schema_version: REPORT_SCHEMA_VERSION,
+ cloud: {
+ assembled: {
+ specifications: device && device.specifications ? device.specifications : null,
+ properties: device && device.properties ? device.properties : null,
+ thing_model: device && device.thing_model ? device.thing_model : null,
+ },
+ raw: {
+ device_list_entry: buildDeviceListEntryReport(listDeviceEntry),
+ device_specification: normalizeSettledResponse(
+ buildRequest('GET', `${API.VERSION_1_2}/devices/${deviceId}/specification`),
+ specResult,
+ ),
+ device_details: normalizeSettledResponse(
+ buildRequest('GET', `${API.VERSION_1_0}/devices/${deviceId}`),
+ detailsResult,
+ ),
+ thing_shadow_properties: normalizeSettledResponse(
+ buildRequest('GET', `${API.VERSION_2_0}/thing/${deviceId}/shadow/properties`),
+ propsResult,
+ ),
+ thing_model: normalizeSettledResponse(
+ buildRequest('GET', `${API.VERSION_2_0}/thing/${deviceId}/model`),
+ modelResult,
+ ),
+ },
+ },
+ });
+
+module.exports = {
+ REPORT_SCHEMA_VERSION,
+ buildCloudReport,
+ buildLocalScanReport,
+ mergeTuyaReport,
+ withTuyaReport,
+};
diff --git a/server/services/tuya/package-lock.json b/server/services/tuya/package-lock.json
index a760b2762b..6d1c8f23b8 100644
--- a/server/services/tuya/package-lock.json
+++ b/server/services/tuya/package-lock.json
@@ -18,7 +18,21 @@
"win32"
],
"dependencies": {
- "@tuya/tuya-connector-nodejs": "^2.1.2"
+ "@demirdeniz/tuyapi-newgen": "^8.1.5",
+ "@tuya/tuya-connector-nodejs": "^2.1.2",
+ "tuyapi": "^7.7.1"
+ }
+ },
+ "node_modules/@demirdeniz/tuyapi-newgen": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/@demirdeniz/tuyapi-newgen/-/tuyapi-newgen-8.1.5.tgz",
+ "integrity": "sha512-AfVQ2g2G8jtGyNbQd+Iw4SXx4SV+bT4w3I6B6BxXodzvFfAnBFNRiezfs8aHqlhmqpjH1d4mVnP42trZ7rIeNw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.7",
+ "p-queue": "6.6.2",
+ "p-retry": "4.6.2",
+ "p-timeout": "3.2.0"
}
},
"node_modules/@tuya/tuya-connector-nodejs": {
@@ -30,6 +44,12 @@
"qs": "^6.10.1"
}
},
+ "node_modules/@types/retry": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
+ "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==",
+ "license": "MIT"
+ },
"node_modules/axios": {
"version": "0.21.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz",
@@ -50,6 +70,29 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eventemitter3": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
+ "license": "MIT"
+ },
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
@@ -109,6 +152,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
"node_modules/object-inspect": {
"version": "1.12.3",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz",
@@ -117,6 +166,56 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/p-finally": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
+ "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/p-queue": {
+ "version": "6.6.2",
+ "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
+ "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
+ "license": "MIT",
+ "dependencies": {
+ "eventemitter3": "^4.0.4",
+ "p-timeout": "^3.2.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-retry": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
+ "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/retry": "0.12.0",
+ "retry": "^0.13.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/p-timeout": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
+ "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
+ "license": "MIT",
+ "dependencies": {
+ "p-finally": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/qs": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.11.1.tgz",
@@ -131,6 +230,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/retry": {
+ "version": "0.13.1",
+ "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
+ "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/side-channel": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
@@ -143,6 +251,18 @@
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
+ },
+ "node_modules/tuyapi": {
+ "version": "7.7.1",
+ "resolved": "https://registry.npmjs.org/tuyapi/-/tuyapi-7.7.1.tgz",
+ "integrity": "sha512-aJHaW0WOhW5y1XLvOOy2G6/SGMUbCTkKgfF8ub8YyhrGkTR6abSB4YNJBXaILr+4UFCBv3cF2cfVjXMeUQ0vYg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "p-queue": "6.6.2",
+ "p-retry": "4.6.2",
+ "p-timeout": "3.2.0"
+ }
}
}
}
diff --git a/server/services/tuya/package.json b/server/services/tuya/package.json
index 5047ac17dc..bf3d58558c 100644
--- a/server/services/tuya/package.json
+++ b/server/services/tuya/package.json
@@ -13,6 +13,8 @@
"arm64"
],
"dependencies": {
- "@tuya/tuya-connector-nodejs": "^2.1.2"
+ "@demirdeniz/tuyapi-newgen": "^8.1.5",
+ "@tuya/tuya-connector-nodejs": "^2.1.2",
+ "tuyapi": "^7.7.1"
}
}
diff --git a/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/cloud-status.json b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/cloud-status.json
new file mode 100644
index 0000000000..8011b92d7f
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/cloud-status.json
@@ -0,0 +1,37 @@
+{
+ "result": {
+ "properties": [
+ { "code": "power_a", "value": 706 },
+ { "code": "direction_a", "value": "FORWARD" },
+ { "code": "tbd", "value": false },
+ { "code": "direction_b", "value": "REVERSE" },
+ { "code": "power_b", "value": 0 },
+ { "code": "energy_forword_a", "value": 149241 },
+ { "code": "energy_reverse_a", "value": 43222 },
+ { "code": "energy_forword_b", "value": 0 },
+ { "code": "energy_reserse_b", "value": 0 },
+ { "code": "power_factor", "value": 73 },
+ { "code": "freq", "value": 4927 },
+ { "code": "voltage_a", "value": 2352 },
+ { "code": "current_a", "value": 410 },
+ { "code": "current_b", "value": 0 },
+ { "code": "total_power", "value": 706 },
+ { "code": "voltage_coef", "value": 1000 },
+ { "code": "current_a_calibration", "value": 1000 },
+ { "code": "power_a_calibration", "value": 1000 },
+ { "code": "energy_a_calibration_fwd", "value": 1000 },
+ { "code": "coef_a_reset", "value": false },
+ { "code": "power_factor_b", "value": 100 },
+ { "code": "freq_calibration", "value": 1000 },
+ { "code": "current_b_calibration", "value": 1000 },
+ { "code": "power_b_calibration", "value": 1000 },
+ { "code": "energy_b_calibration_fwd", "value": 1000 },
+ { "code": "coef_b_reset", "value": false },
+ { "code": "energy_a_calibration_rev", "value": 1000 },
+ { "code": "energy_b_calibration_rev", "value": 1000 },
+ { "code": "report_rate_control", "value": 10 },
+ { "code": "forward_energy_total", "value": 149241 },
+ { "code": "reverse_energy_total", "value": 43222 }
+ ]
+ }
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-device.json b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-device.json
new file mode 100644
index 0000000000..e45c49a691
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-device.json
@@ -0,0 +1,223 @@
+{
+ "name": "Production solaire",
+ "external_id": "tuya:smart-meter-device-id",
+ "selector": "tuya:smart-meter-device-id",
+ "device_type": "smart-meter",
+ "model": "Smart Meter",
+ "product_id": "bbcg1hrkrj5rifsd",
+ "product_key": null,
+ "online": true,
+ "poll_frequency": 10000,
+ "should_poll": true,
+ "params": {
+ "CLOUD_READ_STRATEGY": "shadow",
+ "DEVICE_ID": "smart-meter-device-id",
+ "LOCAL_KEY": "local-key",
+ "IP_ADDRESS": "10.0.0.20",
+ "CLOUD_IP": "82.0.0.10",
+ "LOCAL_OVERRIDE": true,
+ "PROTOCOL_VERSION": "3.3",
+ "PRODUCT_ID": "bbcg1hrkrj5rifsd"
+ },
+ "tuya_mapping": {
+ "ignored_local_dps": [
+ "102",
+ "103",
+ "104",
+ "110",
+ "116",
+ "117",
+ "118",
+ "119",
+ "120",
+ "121",
+ "122",
+ "123",
+ "124",
+ "125",
+ "126",
+ "127",
+ "128",
+ "129"
+ ],
+ "ignored_cloud_codes": [
+ "coef_a_reset",
+ "coef_b_reset",
+ "current_a_calibration",
+ "current_b_calibration",
+ "direction_a",
+ "direction_b",
+ "energy_a_calibration_fwd",
+ "energy_a_calibration_rev",
+ "energy_b_calibration_fwd",
+ "energy_b_calibration_rev",
+ "freq",
+ "freq_calibration",
+ "power_a_calibration",
+ "power_b_calibration",
+ "power_factor",
+ "power_factor_b",
+ "report_rate_control",
+ "tbd",
+ "voltage_coef"
+ ]
+ },
+ "features": [
+ {
+ "name": "energy_reserse_b",
+ "external_id": "tuya:smart-meter-device-id:energy_reserse_b",
+ "selector": "tuya:smart-meter-device-id:energy_reserse_b",
+ "category": "energy-sensor",
+ "type": "export-index",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 99999999,
+ "unit": "kilowatt-hour",
+ "scale": 2
+ },
+ {
+ "name": "energy_reverse_a",
+ "external_id": "tuya:smart-meter-device-id:energy_reverse_a",
+ "selector": "tuya:smart-meter-device-id:energy_reverse_a",
+ "category": "energy-sensor",
+ "type": "export-index",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 99999999,
+ "unit": "kilowatt-hour",
+ "scale": 2
+ },
+ {
+ "name": "reverse_energy_total",
+ "external_id": "tuya:smart-meter-device-id:reverse_energy_total",
+ "selector": "tuya:smart-meter-device-id:reverse_energy_total",
+ "category": "energy-sensor",
+ "type": "export-index",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 99999999,
+ "unit": "kilowatt-hour",
+ "scale": 2
+ },
+ {
+ "name": "current_a",
+ "external_id": "tuya:smart-meter-device-id:current_a",
+ "selector": "tuya:smart-meter-device-id:current_a",
+ "category": "energy-sensor",
+ "type": "current",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 1000000,
+ "unit": "milliampere",
+ "scale": 0
+ },
+ {
+ "name": "current_b",
+ "external_id": "tuya:smart-meter-device-id:current_b",
+ "selector": "tuya:smart-meter-device-id:current_b",
+ "category": "energy-sensor",
+ "type": "current",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 1000000,
+ "unit": "milliampere",
+ "scale": 0
+ },
+ {
+ "name": "energy_forword_a",
+ "external_id": "tuya:smart-meter-device-id:energy_forword_a",
+ "selector": "tuya:smart-meter-device-id:energy_forword_a",
+ "category": "energy-sensor",
+ "type": "energy",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 99999999,
+ "unit": "kilowatt-hour",
+ "scale": 2
+ },
+ {
+ "name": "energy_forword_b",
+ "external_id": "tuya:smart-meter-device-id:energy_forword_b",
+ "selector": "tuya:smart-meter-device-id:energy_forword_b",
+ "category": "energy-sensor",
+ "type": "energy",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 99999999,
+ "unit": "kilowatt-hour",
+ "scale": 2
+ },
+ {
+ "name": "forward_energy_total",
+ "external_id": "tuya:smart-meter-device-id:forward_energy_total",
+ "selector": "tuya:smart-meter-device-id:forward_energy_total",
+ "category": "energy-sensor",
+ "type": "energy",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 99999999,
+ "unit": "kilowatt-hour",
+ "scale": 2
+ },
+ {
+ "name": "power_a",
+ "external_id": "tuya:smart-meter-device-id:power_a",
+ "selector": "tuya:smart-meter-device-id:power_a",
+ "category": "energy-sensor",
+ "type": "power",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 600000,
+ "unit": "watt",
+ "scale": 1
+ },
+ {
+ "name": "power_b",
+ "external_id": "tuya:smart-meter-device-id:power_b",
+ "selector": "tuya:smart-meter-device-id:power_b",
+ "category": "energy-sensor",
+ "type": "power",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 600000,
+ "unit": "watt",
+ "scale": 1
+ },
+ {
+ "name": "total_power",
+ "external_id": "tuya:smart-meter-device-id:total_power",
+ "selector": "tuya:smart-meter-device-id:total_power",
+ "category": "energy-sensor",
+ "type": "power",
+ "read_only": true,
+ "has_feedback": false,
+ "min": -99999999,
+ "max": 99999999,
+ "unit": "watt",
+ "scale": 1
+ },
+ {
+ "name": "voltage_a",
+ "external_id": "tuya:smart-meter-device-id:voltage_a",
+ "selector": "tuya:smart-meter-device-id:voltage_a",
+ "category": "energy-sensor",
+ "type": "voltage",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 28000,
+ "unit": "volt",
+ "scale": 1
+ }
+ ]
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-events.json b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-events.json
new file mode 100644
index 0000000000..8548b21e72
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-events.json
@@ -0,0 +1,50 @@
+[
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:current_a",
+ "state": 410
+ },
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:current_b",
+ "state": 0
+ },
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:energy_forword_a",
+ "state": 1492.41
+ },
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:energy_forword_b",
+ "state": 0
+ },
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:energy_reserse_b",
+ "state": 0
+ },
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:energy_reverse_a",
+ "state": 432.22
+ },
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:forward_energy_total",
+ "state": 1492.41
+ },
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:power_a",
+ "state": 70.6
+ },
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:power_b",
+ "state": 0
+ },
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:reverse_energy_total",
+ "state": 432.22
+ },
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:total_power",
+ "state": 70.6
+ },
+ {
+ "device_feature_external_id": "tuya:smart-meter-device-id:voltage_a",
+ "state": 235.2
+ }
+]
diff --git a/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-local-mapping.json b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-local-mapping.json
new file mode 100644
index 0000000000..5a1275abde
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-local-mapping.json
@@ -0,0 +1,14 @@
+{
+ "power_a": 101,
+ "power_b": 105,
+ "energy_forword_a": 106,
+ "energy_reverse_a": 107,
+ "energy_forword_b": 108,
+ "energy_reserse_b": 109,
+ "voltage_a": 112,
+ "current_a": 113,
+ "current_b": 114,
+ "total_power": 115,
+ "forward_energy_total": 130,
+ "reverse_energy_total": 131
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/input-device.json b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/input-device.json
new file mode 100644
index 0000000000..420f19217f
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/input-device.json
@@ -0,0 +1,756 @@
+{
+ "id": "smart-meter-device-id",
+ "name": "Production solaire",
+ "selector": "tuya-bf8a5bf8acc4540a41rere",
+ "product_name": "Smart Meter",
+ "model": "Smart Meter",
+ "external_id": "tuya:bf8a5bf8acc4540a41rere",
+ "service_id": "966ff9b2-7d5d-4c88-aa54-80c406d5743d",
+ "product_id": "bbcg1hrkrj5rifsd",
+ "product_key": null,
+ "online": true,
+ "protocol_version": "3.3",
+ "local_key": "local-key",
+ "ip": "10.0.0.20",
+ "cloud_ip": "82.0.0.10",
+ "local_override": true,
+ "specifications": {},
+ "properties": {
+ "properties": [
+ {
+ "code": "power_a",
+ "custom_name": "",
+ "dp_id": 101,
+ "time": 1771951460001,
+ "type": "value",
+ "value": 706
+ },
+ {
+ "code": "direction_a",
+ "custom_name": "",
+ "dp_id": 102,
+ "time": 1771951348019,
+ "type": "enum",
+ "value": "FORWARD"
+ },
+ {
+ "code": "tbd",
+ "custom_name": "",
+ "dp_id": 103,
+ "time": 1743762507831,
+ "type": "bool",
+ "value": false
+ },
+ {
+ "code": "direction_b",
+ "custom_name": "",
+ "dp_id": 104,
+ "time": 1743767172287,
+ "type": "enum",
+ "value": "REVERSE"
+ },
+ {
+ "code": "power_b",
+ "custom_name": "",
+ "dp_id": 105,
+ "time": 1771951348019,
+ "type": "value",
+ "value": 0
+ },
+ {
+ "code": "energy_forword_a",
+ "custom_name": "",
+ "dp_id": 106,
+ "time": 1771951345936,
+ "type": "value",
+ "value": 149241
+ },
+ {
+ "code": "energy_reverse_a",
+ "custom_name": "",
+ "dp_id": 107,
+ "time": 1771951348019,
+ "type": "value",
+ "value": 43222
+ },
+ {
+ "code": "energy_forword_b",
+ "custom_name": "",
+ "dp_id": 108,
+ "time": 1771951348019,
+ "type": "value",
+ "value": 0
+ },
+ {
+ "code": "energy_reserse_b",
+ "custom_name": "",
+ "dp_id": 109,
+ "time": 1771951348019,
+ "type": "value",
+ "value": 0
+ },
+ {
+ "code": "power_factor",
+ "custom_name": "",
+ "dp_id": 110,
+ "time": 1771951438623,
+ "type": "value",
+ "value": 73
+ },
+ {
+ "code": "freq",
+ "custom_name": "",
+ "dp_id": 111,
+ "time": 1771951449324,
+ "type": "value",
+ "value": 4927
+ },
+ {
+ "code": "voltage_a",
+ "custom_name": "",
+ "dp_id": 112,
+ "time": 1771951459889,
+ "type": "value",
+ "value": 2352
+ },
+ {
+ "code": "current_a",
+ "custom_name": "",
+ "dp_id": 113,
+ "time": 1771951449206,
+ "type": "value",
+ "value": 410
+ },
+ {
+ "code": "current_b",
+ "custom_name": "",
+ "dp_id": 114,
+ "time": 1771951348019,
+ "type": "value",
+ "value": 0
+ },
+ {
+ "code": "total_power",
+ "custom_name": "",
+ "dp_id": 115,
+ "time": 1771951460016,
+ "type": "value",
+ "value": 706
+ },
+ {
+ "code": "voltage_coef",
+ "custom_name": "",
+ "dp_id": 116,
+ "time": 1771951345564,
+ "type": "value",
+ "value": 1000
+ },
+ {
+ "code": "current_a_calibration",
+ "custom_name": "",
+ "dp_id": 117,
+ "time": 1771951345590,
+ "type": "value",
+ "value": 1000
+ },
+ {
+ "code": "power_a_calibration",
+ "custom_name": "",
+ "dp_id": 118,
+ "time": 1771951345699,
+ "type": "value",
+ "value": 1000
+ },
+ {
+ "code": "energy_a_calibration_fwd",
+ "custom_name": "",
+ "dp_id": 119,
+ "time": 1771951345718,
+ "type": "value",
+ "value": 1000
+ },
+ {
+ "code": "coef_a_reset",
+ "custom_name": "",
+ "dp_id": 120,
+ "time": 1743762507831,
+ "type": "bool",
+ "value": false
+ },
+ {
+ "code": "power_factor_b",
+ "custom_name": "",
+ "dp_id": 121,
+ "time": 1771951348019,
+ "type": "value",
+ "value": 100
+ },
+ {
+ "code": "freq_calibration",
+ "custom_name": "",
+ "dp_id": 122,
+ "time": 1771951345575,
+ "type": "value",
+ "value": 1000
+ },
+ {
+ "code": "current_b_calibration",
+ "custom_name": "",
+ "dp_id": 123,
+ "time": 1771951345731,
+ "type": "value",
+ "value": 1000
+ },
+ {
+ "code": "power_b_calibration",
+ "custom_name": "",
+ "dp_id": 124,
+ "time": 1771951345761,
+ "type": "value",
+ "value": 1000
+ },
+ {
+ "code": "energy_b_calibration_fwd",
+ "custom_name": "",
+ "dp_id": 125,
+ "time": 1771951348019,
+ "type": "value",
+ "value": 1000
+ },
+ {
+ "code": "coef_b_reset",
+ "custom_name": "",
+ "dp_id": 126,
+ "time": 1743762507831,
+ "type": "bool",
+ "value": false
+ },
+ {
+ "code": "energy_a_calibration_rev",
+ "custom_name": "",
+ "dp_id": 127,
+ "time": 1771951345723,
+ "type": "value",
+ "value": 1000
+ },
+ {
+ "code": "energy_b_calibration_rev",
+ "custom_name": "",
+ "dp_id": 128,
+ "time": 1771951345905,
+ "type": "value",
+ "value": 1000
+ },
+ {
+ "code": "report_rate_control",
+ "custom_name": "",
+ "dp_id": 129,
+ "time": 1771951345921,
+ "type": "value",
+ "value": 10
+ },
+ {
+ "code": "forward_energy_total",
+ "custom_name": "",
+ "dp_id": 130,
+ "time": 1771951345925,
+ "type": "value",
+ "value": 149241
+ },
+ {
+ "code": "reverse_energy_total",
+ "custom_name": "",
+ "dp_id": 131,
+ "time": 1771951348019,
+ "type": "value",
+ "value": 43222
+ }
+ ]
+ },
+ "thing_model": {
+ "modelId": "flu1ic",
+ "services": [
+ {
+ "actions": [],
+ "code": "",
+ "description": "",
+ "events": [],
+ "name": "默认服务",
+ "properties": [
+ {
+ "abilityId": 101,
+ "accessMode": "ro",
+ "code": "power_a",
+ "description": "",
+ "name": "Power_a",
+ "typeSpec": {
+ "type": "value",
+ "max": 600000,
+ "min": 0,
+ "scale": 1,
+ "step": 1,
+ "unit": "W"
+ }
+ },
+ {
+ "abilityId": 102,
+ "accessMode": "ro",
+ "code": "direction_a",
+ "description": "",
+ "name": "Current_Flow_A",
+ "typeSpec": {
+ "type": "enum",
+ "range": ["FORWARD", "REVERSE"]
+ }
+ },
+ {
+ "abilityId": 103,
+ "accessMode": "ro",
+ "code": "tbd",
+ "description": "",
+ "name": "TBD",
+ "typeSpec": {
+ "type": "bool"
+ }
+ },
+ {
+ "abilityId": 104,
+ "accessMode": "ro",
+ "code": "direction_b",
+ "description": "",
+ "name": "Current_FLow_B",
+ "typeSpec": {
+ "type": "enum",
+ "range": ["FORWARD", "REVERSE"]
+ }
+ },
+ {
+ "abilityId": 105,
+ "accessMode": "ro",
+ "code": "power_b",
+ "description": "",
+ "name": "Power_b",
+ "typeSpec": {
+ "type": "value",
+ "max": 600000,
+ "min": 0,
+ "scale": 1,
+ "step": 1,
+ "unit": "W"
+ }
+ },
+ {
+ "abilityId": 106,
+ "accessMode": "ro",
+ "code": "energy_forword_a",
+ "description": "",
+ "name": "Forward Energy-A",
+ "typeSpec": {
+ "type": "value",
+ "max": 99999999,
+ "min": 0,
+ "scale": 2,
+ "step": 1,
+ "unit": "KWH"
+ }
+ },
+ {
+ "abilityId": 107,
+ "accessMode": "ro",
+ "code": "energy_reverse_a",
+ "description": "",
+ "name": "Reverse Energy-A",
+ "typeSpec": {
+ "type": "value",
+ "max": 99999999,
+ "min": 0,
+ "scale": 2,
+ "step": 1,
+ "unit": "KWH"
+ }
+ },
+ {
+ "abilityId": 108,
+ "accessMode": "ro",
+ "code": "energy_forword_b",
+ "description": "",
+ "name": "Forward Energy-B",
+ "typeSpec": {
+ "type": "value",
+ "max": 99999999,
+ "min": 0,
+ "scale": 2,
+ "step": 1,
+ "unit": "KWH"
+ }
+ },
+ {
+ "abilityId": 109,
+ "accessMode": "ro",
+ "code": "energy_reserse_b",
+ "description": "",
+ "name": "Reverse Energy-B",
+ "typeSpec": {
+ "type": "value",
+ "max": 99999999,
+ "min": 0,
+ "scale": 2,
+ "step": 1,
+ "unit": "KWH"
+ }
+ },
+ {
+ "abilityId": 110,
+ "accessMode": "ro",
+ "code": "power_factor",
+ "description": "",
+ "name": "Power_factor",
+ "typeSpec": {
+ "type": "value",
+ "max": 100,
+ "min": 0,
+ "scale": 2,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 111,
+ "accessMode": "ro",
+ "code": "freq",
+ "description": "",
+ "name": "AC_Freq",
+ "typeSpec": {
+ "type": "value",
+ "max": 10000,
+ "min": 0,
+ "scale": 2,
+ "step": 1,
+ "unit": "Hz"
+ }
+ },
+ {
+ "abilityId": 112,
+ "accessMode": "ro",
+ "code": "voltage_a",
+ "description": "",
+ "name": "Voltage",
+ "typeSpec": {
+ "type": "value",
+ "max": 28000,
+ "min": 0,
+ "scale": 1,
+ "step": 1,
+ "unit": "V"
+ }
+ },
+ {
+ "abilityId": 113,
+ "accessMode": "ro",
+ "code": "current_a",
+ "description": "",
+ "name": "Current_a",
+ "typeSpec": {
+ "type": "value",
+ "max": 1000000,
+ "min": 0,
+ "scale": 0,
+ "step": 1,
+ "unit": "mA"
+ }
+ },
+ {
+ "abilityId": 114,
+ "accessMode": "ro",
+ "code": "current_b",
+ "description": "",
+ "name": "Current_b",
+ "typeSpec": {
+ "type": "value",
+ "max": 1000000,
+ "min": 0,
+ "scale": 0,
+ "step": 1,
+ "unit": "mA"
+ }
+ },
+ {
+ "abilityId": 115,
+ "accessMode": "ro",
+ "code": "total_power",
+ "description": "",
+ "name": " Total_Power",
+ "typeSpec": {
+ "type": "value",
+ "max": 99999999,
+ "min": -99999999,
+ "scale": 1,
+ "step": 1,
+ "unit": "W"
+ }
+ },
+ {
+ "abilityId": 116,
+ "accessMode": "rw",
+ "code": "voltage_coef",
+ "description": "",
+ "name": "电压校准",
+ "typeSpec": {
+ "type": "value",
+ "max": 1200,
+ "min": 800,
+ "scale": 3,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 117,
+ "accessMode": "rw",
+ "code": "current_a_calibration",
+ "description": "",
+ "name": "current_a_calibration",
+ "typeSpec": {
+ "type": "value",
+ "max": 1200,
+ "min": 800,
+ "scale": 3,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 118,
+ "accessMode": "rw",
+ "code": "power_a_calibration",
+ "description": "",
+ "name": "power_a_calibration",
+ "typeSpec": {
+ "type": "value",
+ "max": 1200,
+ "min": 800,
+ "scale": 3,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 119,
+ "accessMode": "rw",
+ "code": "energy_a_calibration_fwd",
+ "description": "",
+ "name": "energy_a_calibration_fwd",
+ "typeSpec": {
+ "type": "value",
+ "max": 1200,
+ "min": 800,
+ "scale": 3,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 120,
+ "accessMode": "wr",
+ "code": "coef_a_reset",
+ "description": "",
+ "name": "coef_a_reset",
+ "typeSpec": {
+ "type": "bool"
+ }
+ },
+ {
+ "abilityId": 121,
+ "accessMode": "ro",
+ "code": "power_factor_b",
+ "description": "",
+ "name": "Power_factor_b",
+ "typeSpec": {
+ "type": "value",
+ "max": 100,
+ "min": 0,
+ "scale": 2,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 122,
+ "accessMode": "rw",
+ "code": "freq_calibration",
+ "description": "",
+ "name": "频率校准",
+ "typeSpec": {
+ "type": "value",
+ "max": 1200,
+ "min": 800,
+ "scale": 3,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 123,
+ "accessMode": "rw",
+ "code": "current_b_calibration",
+ "description": "",
+ "name": "current_b_calibration",
+ "typeSpec": {
+ "type": "value",
+ "max": 1200,
+ "min": 800,
+ "scale": 3,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 124,
+ "accessMode": "rw",
+ "code": "power_b_calibration",
+ "description": "",
+ "name": "power_b_calibration",
+ "typeSpec": {
+ "type": "value",
+ "max": 1200,
+ "min": 800,
+ "scale": 3,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 125,
+ "accessMode": "rw",
+ "code": "energy_b_calibration_fwd",
+ "description": "",
+ "name": "energy_b_calibration_fwd",
+ "typeSpec": {
+ "type": "value",
+ "max": 1200,
+ "min": 800,
+ "scale": 3,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 126,
+ "accessMode": "wr",
+ "code": "coef_b_reset",
+ "description": "",
+ "name": "coef_b_reset",
+ "typeSpec": {
+ "type": "bool"
+ }
+ },
+ {
+ "abilityId": 127,
+ "accessMode": "rw",
+ "code": "energy_a_calibration_rev",
+ "description": "",
+ "name": "energy_a_calibration_rev",
+ "typeSpec": {
+ "type": "value",
+ "max": 1200,
+ "min": 800,
+ "scale": 3,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 128,
+ "accessMode": "rw",
+ "code": "energy_b_calibration_rev",
+ "description": "",
+ "name": "energy_b_calibration_rev",
+ "typeSpec": {
+ "type": "value",
+ "max": 1200,
+ "min": 800,
+ "scale": 3,
+ "step": 1,
+ "unit": ""
+ }
+ },
+ {
+ "abilityId": 129,
+ "accessMode": "rw",
+ "code": "report_rate_control",
+ "description": "",
+ "name": "上报频率",
+ "typeSpec": {
+ "type": "value",
+ "max": 60,
+ "min": 3,
+ "scale": 0,
+ "step": 1,
+ "unit": "s"
+ }
+ },
+ {
+ "abilityId": 130,
+ "accessMode": "ro",
+ "code": "forward_energy_total",
+ "description": "",
+ "name": "forward_energy_total",
+ "typeSpec": {
+ "type": "value",
+ "max": 99999999,
+ "min": 0,
+ "scale": 2,
+ "step": 1,
+ "unit": "KWH"
+ }
+ },
+ {
+ "abilityId": 131,
+ "accessMode": "ro",
+ "code": "reverse_energy_total",
+ "description": "",
+ "name": "reverse_energy_total",
+ "typeSpec": {
+ "type": "value",
+ "max": 99999999,
+ "min": 0,
+ "scale": 2,
+ "step": 1,
+ "unit": "KWH"
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "thing_model_raw": "{\"modelId\":\"flu1ic\",\"services\":[{\"actions\":[],\"code\":\"\",\"description\":\"\",\"events\":[],\"name\":\"默认服务\",\"properties\":[{\"abilityId\":101,\"accessMode\":\"ro\",\"code\":\"power_a\",\"description\":\"\",\"name\":\"Power_a\",\"typeSpec\":{\"type\":\"value\",\"max\":600000,\"min\":0,\"scale\":1,\"step\":1,\"unit\":\"W\"}},{\"abilityId\":102,\"accessMode\":\"ro\",\"code\":\"direction_a\",\"description\":\"\",\"name\":\"Current_Flow_A\",\"typeSpec\":{\"type\":\"enum\",\"range\":[\"FORWARD\",\"REVERSE\"]}},{\"abilityId\":103,\"accessMode\":\"ro\",\"code\":\"tbd\",\"description\":\"\",\"name\":\"TBD\",\"typeSpec\":{\"type\":\"bool\"}},{\"abilityId\":104,\"accessMode\":\"ro\",\"code\":\"direction_b\",\"description\":\"\",\"name\":\"Current_FLow_B\",\"typeSpec\":{\"type\":\"enum\",\"range\":[\"FORWARD\",\"REVERSE\"]}},{\"abilityId\":105,\"accessMode\":\"ro\",\"code\":\"power_b\",\"description\":\"\",\"name\":\"Power_b\",\"typeSpec\":{\"type\":\"value\",\"max\":600000,\"min\":0,\"scale\":1,\"step\":1,\"unit\":\"W\"}},{\"abilityId\":106,\"accessMode\":\"ro\",\"code\":\"energy_forword_a\",\"description\":\"\",\"name\":\"Forward Energy-A\",\"typeSpec\":{\"type\":\"value\",\"max\":99999999,\"min\":0,\"scale\":2,\"step\":1,\"unit\":\"KWH\"}},{\"abilityId\":107,\"accessMode\":\"ro\",\"code\":\"energy_reverse_a\",\"description\":\"\",\"name\":\"Reverse Energy-A\",\"typeSpec\":{\"type\":\"value\",\"max\":99999999,\"min\":0,\"scale\":2,\"step\":1,\"unit\":\"KWH\"}},{\"abilityId\":108,\"accessMode\":\"ro\",\"code\":\"energy_forword_b\",\"description\":\"\",\"name\":\"Forward Energy-B\",\"typeSpec\":{\"type\":\"value\",\"max\":99999999,\"min\":0,\"scale\":2,\"step\":1,\"unit\":\"KWH\"}},{\"abilityId\":109,\"accessMode\":\"ro\",\"code\":\"energy_reserse_b\",\"description\":\"\",\"name\":\"Reverse Energy-B\",\"typeSpec\":{\"type\":\"value\",\"max\":99999999,\"min\":0,\"scale\":2,\"step\":1,\"unit\":\"KWH\"}},{\"abilityId\":110,\"accessMode\":\"ro\",\"code\":\"power_factor\",\"description\":\"\",\"name\":\"Power_factor\",\"typeSpec\":{\"type\":\"value\",\"max\":100,\"min\":0,\"scale\":2,\"step\":1,\"unit\":\"\"}},{\"abilityId\":111,\"accessMode\":\"ro\",\"code\":\"freq\",\"description\":\"\",\"name\":\"AC_Freq\",\"typeSpec\":{\"type\":\"value\",\"max\":10000,\"min\":0,\"scale\":2,\"step\":1,\"unit\":\"Hz\"}},{\"abilityId\":112,\"accessMode\":\"ro\",\"code\":\"voltage_a\",\"description\":\"\",\"name\":\"Voltage\",\"typeSpec\":{\"type\":\"value\",\"max\":28000,\"min\":0,\"scale\":1,\"step\":1,\"unit\":\"V\"}},{\"abilityId\":113,\"accessMode\":\"ro\",\"code\":\"current_a\",\"description\":\"\",\"name\":\"Current_a\",\"typeSpec\":{\"type\":\"value\",\"max\":1000000,\"min\":0,\"scale\":0,\"step\":1,\"unit\":\"mA\"}},{\"abilityId\":114,\"accessMode\":\"ro\",\"code\":\"current_b\",\"description\":\"\",\"name\":\"Current_b\",\"typeSpec\":{\"type\":\"value\",\"max\":1000000,\"min\":0,\"scale\":0,\"step\":1,\"unit\":\"mA\"}},{\"abilityId\":115,\"accessMode\":\"ro\",\"code\":\"total_power\",\"description\":\"\",\"name\":\" Total_Power\",\"typeSpec\":{\"type\":\"value\",\"max\":99999999,\"min\":-99999999,\"scale\":1,\"step\":1,\"unit\":\"W\"}},{\"abilityId\":116,\"accessMode\":\"rw\",\"code\":\"voltage_coef\",\"description\":\"\",\"name\":\"电压校准\",\"typeSpec\":{\"type\":\"value\",\"max\":1200,\"min\":800,\"scale\":3,\"step\":1,\"unit\":\"\"}},{\"abilityId\":117,\"accessMode\":\"rw\",\"code\":\"current_a_calibration\",\"description\":\"\",\"name\":\"current_a_calibration\",\"typeSpec\":{\"type\":\"value\",\"max\":1200,\"min\":800,\"scale\":3,\"step\":1,\"unit\":\"\"}},{\"abilityId\":118,\"accessMode\":\"rw\",\"code\":\"power_a_calibration\",\"description\":\"\",\"name\":\"power_a_calibration\",\"typeSpec\":{\"type\":\"value\",\"max\":1200,\"min\":800,\"scale\":3,\"step\":1,\"unit\":\"\"}},{\"abilityId\":119,\"accessMode\":\"rw\",\"code\":\"energy_a_calibration_fwd\",\"description\":\"\",\"name\":\"energy_a_calibration_fwd\",\"typeSpec\":{\"type\":\"value\",\"max\":1200,\"min\":800,\"scale\":3,\"step\":1,\"unit\":\"\"}},{\"abilityId\":120,\"accessMode\":\"wr\",\"code\":\"coef_a_reset\",\"description\":\"\",\"name\":\"coef_a_reset\",\"typeSpec\":{\"type\":\"bool\"}},{\"abilityId\":121,\"accessMode\":\"ro\",\"code\":\"power_factor_b\",\"description\":\"\",\"name\":\"Power_factor_b\",\"typeSpec\":{\"type\":\"value\",\"max\":100,\"min\":0,\"scale\":2,\"step\":1,\"unit\":\"\"}},{\"abilityId\":122,\"accessMode\":\"rw\",\"code\":\"freq_calibration\",\"description\":\"\",\"name\":\"频率校准\",\"typeSpec\":{\"type\":\"value\",\"max\":1200,\"min\":800,\"scale\":3,\"step\":1,\"unit\":\"\"}},{\"abilityId\":123,\"accessMode\":\"rw\",\"code\":\"current_b_calibration\",\"description\":\"\",\"name\":\"current_b_calibration\",\"typeSpec\":{\"type\":\"value\",\"max\":1200,\"min\":800,\"scale\":3,\"step\":1,\"unit\":\"\"}},{\"abilityId\":124,\"accessMode\":\"rw\",\"code\":\"power_b_calibration\",\"description\":\"\",\"name\":\"power_b_calibration\",\"typeSpec\":{\"type\":\"value\",\"max\":1200,\"min\":800,\"scale\":3,\"step\":1,\"unit\":\"\"}},{\"abilityId\":125,\"accessMode\":\"rw\",\"code\":\"energy_b_calibration_fwd\",\"description\":\"\",\"name\":\"energy_b_calibration_fwd\",\"typeSpec\":{\"type\":\"value\",\"max\":1200,\"min\":800,\"scale\":3,\"step\":1,\"unit\":\"\"}},{\"abilityId\":126,\"accessMode\":\"wr\",\"code\":\"coef_b_reset\",\"description\":\"\",\"name\":\"coef_b_reset\",\"typeSpec\":{\"type\":\"bool\"}},{\"abilityId\":127,\"accessMode\":\"rw\",\"code\":\"energy_a_calibration_rev\",\"description\":\"\",\"name\":\"energy_a_calibration_rev\",\"typeSpec\":{\"type\":\"value\",\"max\":1200,\"min\":800,\"scale\":3,\"step\":1,\"unit\":\"\"}},{\"abilityId\":128,\"accessMode\":\"rw\",\"code\":\"energy_b_calibration_rev\",\"description\":\"\",\"name\":\"energy_b_calibration_rev\",\"typeSpec\":{\"type\":\"value\",\"max\":1200,\"min\":800,\"scale\":3,\"step\":1,\"unit\":\"\"}},{\"abilityId\":129,\"accessMode\":\"rw\",\"code\":\"report_rate_control\",\"description\":\"\",\"name\":\"上报频率\",\"typeSpec\":{\"type\":\"value\",\"max\":60,\"min\":3,\"scale\":0,\"step\":1,\"unit\":\"s\"}},{\"abilityId\":130,\"accessMode\":\"ro\",\"code\":\"forward_energy_total\",\"description\":\"\",\"name\":\"forward_energy_total\",\"typeSpec\":{\"type\":\"value\",\"max\":99999999,\"min\":0,\"scale\":2,\"step\":1,\"unit\":\"KWH\"}},{\"abilityId\":131,\"accessMode\":\"ro\",\"code\":\"reverse_energy_total\",\"description\":\"\",\"name\":\"reverse_energy_total\",\"typeSpec\":{\"type\":\"value\",\"max\":99999999,\"min\":0,\"scale\":2,\"step\":1,\"unit\":\"KWH\"}}]}]}",
+ "features": [],
+ "params": [
+ {
+ "name": "DEVICE_ID",
+ "value": "bf8a5bf8acc4540a41rere"
+ },
+ {
+ "name": "LOCAL_KEY",
+ "value": "***"
+ },
+ {
+ "name": "CLOUD_IP",
+ "value": "62.x.x.x"
+ },
+ {
+ "name": "LOCAL_OVERRIDE",
+ "value": true
+ },
+ {
+ "name": "PRODUCT_ID",
+ "value": "bbcg1hrkrj5rifsd"
+ }
+ ],
+ "local_poll": {
+ "status": null,
+ "error": null,
+ "protocol": null,
+ "dps": null
+ }
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/local-dps.json b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/local-dps.json
new file mode 100644
index 0000000000..ab75f109b5
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/local-dps.json
@@ -0,0 +1,33 @@
+{
+ "101": 706,
+ "102": "FORWARD",
+ "103": false,
+ "104": "REVERSE",
+ "105": 0,
+ "106": 149241,
+ "107": 43222,
+ "108": 0,
+ "109": 0,
+ "110": 73,
+ "111": 4927,
+ "112": 2352,
+ "113": 410,
+ "114": 0,
+ "115": 706,
+ "116": 1000,
+ "117": 1000,
+ "118": 1000,
+ "119": 1000,
+ "120": false,
+ "121": 100,
+ "122": 1000,
+ "123": 1000,
+ "124": 1000,
+ "125": 1000,
+ "126": false,
+ "127": 1000,
+ "128": 1000,
+ "129": 10,
+ "130": 149241,
+ "131": 43222
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/manifest.js b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/manifest.js
new file mode 100644
index 0000000000..db72b419b6
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/manifest.js
@@ -0,0 +1,22 @@
+module.exports = {
+ name: 'Production solaire',
+ convertDevice: {
+ input: './input-device.json',
+ expected: './expected-device.json',
+ },
+ pollCloud: {
+ device: './poll-device.json',
+ response: './cloud-status.json',
+ expectedEvents: './expected-events.json',
+ },
+ pollLocal: {
+ device: './poll-device.json',
+ dps: './local-dps.json',
+ expectedEvents: './expected-events.json',
+ expectedCloudRequests: 0,
+ },
+ localMapping: {
+ device: './poll-device.json',
+ expected: './expected-local-mapping.json',
+ },
+};
diff --git a/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/poll-device.json b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/poll-device.json
new file mode 100644
index 0000000000..6ec33d0774
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/poll-device.json
@@ -0,0 +1,155 @@
+{
+ "external_id": "tuya:smart-meter-device-id",
+ "device_type": "smart-meter",
+ "params": [
+ { "name": "DEVICE_ID", "value": "smart-meter-device-id" },
+ { "name": "LOCAL_KEY", "value": "local-key" },
+ { "name": "IP_ADDRESS", "value": "10.0.0.20" },
+ { "name": "CLOUD_IP", "value": "82.0.0.10" },
+ { "name": "CLOUD_READ_STRATEGY", "value": "shadow" },
+ { "name": "LOCAL_OVERRIDE", "value": true },
+ { "name": "PROTOCOL_VERSION", "value": "3.3" },
+ { "name": "PRODUCT_ID", "value": "bbcg1hrkrj5rifsd" }
+ ],
+ "features": [
+ {
+ "external_id": "tuya:smart-meter-device-id:power_a",
+ "selector": "tuya:smart-meter-device-id:power_a",
+ "category": "energy-sensor",
+ "type": "power",
+ "last_value": 0,
+ "scale": 1
+ },
+ {
+ "external_id": "tuya:smart-meter-device-id:power_b",
+ "selector": "tuya:smart-meter-device-id:power_b",
+ "category": "energy-sensor",
+ "type": "power",
+ "last_value": 0,
+ "scale": 1
+ },
+ {
+ "external_id": "tuya:smart-meter-device-id:energy_forword_a",
+ "selector": "tuya:smart-meter-device-id:energy_forword_a",
+ "category": "energy-sensor",
+ "type": "energy",
+ "last_value": 0,
+ "scale": 2
+ },
+ {
+ "external_id": "tuya:smart-meter-device-id:energy_reverse_a",
+ "selector": "tuya:smart-meter-device-id:energy_reverse_a",
+ "category": "energy-sensor",
+ "type": "export-index",
+ "last_value": 0,
+ "scale": 2
+ },
+ {
+ "external_id": "tuya:smart-meter-device-id:energy_forword_b",
+ "selector": "tuya:smart-meter-device-id:energy_forword_b",
+ "category": "energy-sensor",
+ "type": "energy",
+ "last_value": 0,
+ "scale": 2
+ },
+ {
+ "external_id": "tuya:smart-meter-device-id:energy_reserse_b",
+ "selector": "tuya:smart-meter-device-id:energy_reserse_b",
+ "category": "energy-sensor",
+ "type": "export-index",
+ "last_value": 0,
+ "scale": 2
+ },
+ {
+ "external_id": "tuya:smart-meter-device-id:voltage_a",
+ "selector": "tuya:smart-meter-device-id:voltage_a",
+ "category": "energy-sensor",
+ "type": "voltage",
+ "last_value": 0,
+ "scale": 1
+ },
+ {
+ "external_id": "tuya:smart-meter-device-id:current_a",
+ "selector": "tuya:smart-meter-device-id:current_a",
+ "category": "energy-sensor",
+ "type": "current",
+ "last_value": 0,
+ "scale": 0
+ },
+ {
+ "external_id": "tuya:smart-meter-device-id:current_b",
+ "selector": "tuya:smart-meter-device-id:current_b",
+ "category": "energy-sensor",
+ "type": "current",
+ "last_value": 0,
+ "scale": 0
+ },
+ {
+ "external_id": "tuya:smart-meter-device-id:total_power",
+ "selector": "tuya:smart-meter-device-id:total_power",
+ "category": "energy-sensor",
+ "type": "power",
+ "scale": 1,
+ "last_value": 0
+ },
+ {
+ "external_id": "tuya:smart-meter-device-id:forward_energy_total",
+ "selector": "tuya:smart-meter-device-id:forward_energy_total",
+ "category": "energy-sensor",
+ "type": "energy",
+ "scale": 2,
+ "last_value": 0
+ },
+ {
+ "external_id": "tuya:smart-meter-device-id:reverse_energy_total",
+ "selector": "tuya:smart-meter-device-id:reverse_energy_total",
+ "category": "energy-sensor",
+ "type": "export-index",
+ "scale": 2,
+ "last_value": 0
+ }
+ ],
+ "tuya_mapping": {
+ "ignored_local_dps": [
+ "102",
+ "103",
+ "104",
+ "110",
+ "116",
+ "117",
+ "118",
+ "119",
+ "120",
+ "121",
+ "122",
+ "123",
+ "124",
+ "125",
+ "126",
+ "127",
+ "128",
+ "129"
+ ],
+ "ignored_cloud_codes": [
+ "coef_a_reset",
+ "coef_b_reset",
+ "current_a_calibration",
+ "current_b_calibration",
+ "direction_a",
+ "direction_b",
+ "energy_a_calibration_fwd",
+ "energy_a_calibration_rev",
+ "energy_b_calibration_fwd",
+ "energy_b_calibration_rev",
+ "freq",
+ "freq_calibration",
+ "power_a_calibration",
+ "power_b_calibration",
+ "power_factor",
+ "power_factor_b",
+ "report_rate_control",
+ "tbd",
+ "voltage_coef"
+ ]
+ }
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/cloud-status.json b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/cloud-status.json
new file mode 100644
index 0000000000..1f9feef8f0
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/cloud-status.json
@@ -0,0 +1,10 @@
+{
+ "result": [
+ { "code": "switch_1", "value": false },
+ { "code": "child_lock", "value": false },
+ { "code": "add_ele", "value": 1 },
+ { "code": "cur_current", "value": 0 },
+ { "code": "cur_power", "value": 0 },
+ { "code": "cur_voltage", "value": 2340 }
+ ]
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-cloud-events.json b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-cloud-events.json
new file mode 100644
index 0000000000..3c08aa8166
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-cloud-events.json
@@ -0,0 +1,26 @@
+[
+ {
+ "device_feature_external_id": "tuya:bf2be3c32ea4d8f561ujmu:add_ele",
+ "state": 0.001
+ },
+ {
+ "device_feature_external_id": "tuya:bf2be3c32ea4d8f561ujmu:child_lock",
+ "state": 0
+ },
+ {
+ "device_feature_external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_current",
+ "state": 0
+ },
+ {
+ "device_feature_external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_power",
+ "state": 0
+ },
+ {
+ "device_feature_external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_voltage",
+ "state": 234
+ },
+ {
+ "device_feature_external_id": "tuya:bf2be3c32ea4d8f561ujmu:switch_1",
+ "state": 0
+ }
+]
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-device.json b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-device.json
new file mode 100644
index 0000000000..7d46c7b509
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-device.json
@@ -0,0 +1,115 @@
+{
+ "name": "Chauffage cabinet",
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu",
+ "device_type": "smart-socket",
+ "model": "LSC Power Plug FR incl. Power meter",
+ "product_id": "b61eihfqeaexn54g",
+ "online": true,
+ "poll_frequency": 10000,
+ "should_poll": true,
+ "params": {
+ "DEVICE_ID": "bf2be3c32ea4d8f561ujmu",
+ "LOCAL_KEY": "local-key",
+ "IP_ADDRESS": "192.168.1.50",
+ "CLOUD_IP": "91.0.0.1",
+ "CLOUD_READ_STRATEGY": "legacy",
+ "LOCAL_OVERRIDE": true,
+ "PROTOCOL_VERSION": "3.5",
+ "PRODUCT_ID": "b61eihfqeaexn54g"
+ },
+ "tuya_mapping": {
+ "ignored_local_dps": ["9", "11", "21", "22", "23", "24", "25", "38", "39", "40", "42", "43", "44"],
+ "ignored_cloud_codes": [
+ "countdown",
+ "countdown_1",
+ "relay_status",
+ "overcharge_switch",
+ "light_mode",
+ "cycle_time",
+ "random_time",
+ "switch_inching",
+ "voltage_coe",
+ "electric_coe",
+ "power_coe",
+ "electricity_coe",
+ "test_bit"
+ ]
+ },
+ "features": [
+ {
+ "name": "switch_1",
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:switch_1",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:switch_1",
+ "category": "switch",
+ "type": "binary",
+ "read_only": false,
+ "has_feedback": false,
+ "min": 0,
+ "max": 1
+ },
+ {
+ "name": "child_lock",
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:child_lock",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:child_lock",
+ "category": "child-lock",
+ "type": "binary",
+ "read_only": false,
+ "has_feedback": false,
+ "min": 0,
+ "max": 1
+ },
+ {
+ "name": "add_ele",
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:add_ele",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:add_ele",
+ "category": "switch",
+ "type": "energy",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 50000,
+ "unit": "kilowatt-hour",
+ "scale": 3
+ },
+ {
+ "name": "cur_current",
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_current",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:cur_current",
+ "category": "switch",
+ "type": "current",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 30000,
+ "unit": "milliampere",
+ "scale": 0
+ },
+ {
+ "name": "cur_power",
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_power",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:cur_power",
+ "category": "switch",
+ "type": "power",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 80000,
+ "unit": "watt",
+ "scale": 1
+ },
+ {
+ "name": "cur_voltage",
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_voltage",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:cur_voltage",
+ "category": "switch",
+ "type": "voltage",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 5000,
+ "unit": "volt",
+ "scale": 1
+ }
+ ]
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-local-events.json b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-local-events.json
new file mode 100644
index 0000000000..0b2a6f9912
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-local-events.json
@@ -0,0 +1,22 @@
+[
+ {
+ "device_feature_external_id": "tuya:bf2be3c32ea4d8f561ujmu:child_lock",
+ "state": 0
+ },
+ {
+ "device_feature_external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_current",
+ "state": 0
+ },
+ {
+ "device_feature_external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_power",
+ "state": 0
+ },
+ {
+ "device_feature_external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_voltage",
+ "state": 226.8
+ },
+ {
+ "device_feature_external_id": "tuya:bf2be3c32ea4d8f561ujmu:switch_1",
+ "state": 0
+ }
+]
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-local-mapping.json b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-local-mapping.json
new file mode 100644
index 0000000000..3def215fa2
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-local-mapping.json
@@ -0,0 +1,8 @@
+{
+ "switch_1": 1,
+ "child_lock": 41,
+ "add_ele": 17,
+ "cur_current": 18,
+ "cur_power": 19,
+ "cur_voltage": 20
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/input-device.json b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/input-device.json
new file mode 100644
index 0000000000..2bced15499
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/input-device.json
@@ -0,0 +1,104 @@
+{
+ "id": "bf2be3c32ea4d8f561ujmu",
+ "name": "Chauffage cabinet",
+ "product_name": "LSC Power Plug FR incl. Power meter",
+ "product_id": "b61eihfqeaexn54g",
+ "local_key": "local-key",
+ "ip": "192.168.1.50",
+ "cloud_ip": "91.0.0.1",
+ "protocol_version": "3.5",
+ "local_override": true,
+ "online": true,
+ "specifications": {
+ "category": "cz",
+ "functions": [
+ {
+ "code": "switch_1",
+ "name": "Switch 1",
+ "type": "Boolean",
+ "values": "{}"
+ },
+ {
+ "code": "child_lock",
+ "name": "Child Lock",
+ "type": "Boolean",
+ "values": "{}"
+ }
+ ],
+ "status": [
+ {
+ "code": "switch_1",
+ "name": "Switch 1",
+ "type": "Boolean",
+ "values": "{}"
+ },
+ {
+ "code": "add_ele",
+ "name": "Electricity",
+ "type": "Integer",
+ "values": "{\"min\":0,\"max\":50000,\"scale\":3,\"step\":100}"
+ },
+ {
+ "code": "cur_current",
+ "name": "Current",
+ "type": "Integer",
+ "values": "{\"unit\":\"mA\",\"min\":0,\"max\":30000,\"scale\":0,\"step\":1}"
+ },
+ {
+ "code": "cur_power",
+ "name": "Power",
+ "type": "Integer",
+ "values": "{\"unit\":\"W\",\"min\":0,\"max\":80000,\"scale\":1,\"step\":1}"
+ },
+ {
+ "code": "cur_voltage",
+ "name": "Voltage",
+ "type": "Integer",
+ "values": "{\"unit\":\"V\",\"min\":0,\"max\":5000,\"scale\":1,\"step\":1}"
+ },
+ {
+ "code": "child_lock",
+ "name": "Child Lock",
+ "type": "Boolean",
+ "values": "{}"
+ }
+ ]
+ },
+ "properties": {
+ "properties": [
+ {
+ "code": "switch_1",
+ "dp_id": 1,
+ "value": false
+ },
+ {
+ "code": "add_ele",
+ "dp_id": 17,
+ "value": 1
+ },
+ {
+ "code": "cur_current",
+ "dp_id": 18,
+ "value": 0
+ },
+ {
+ "code": "cur_power",
+ "dp_id": 19,
+ "value": 0
+ },
+ {
+ "code": "cur_voltage",
+ "dp_id": 20,
+ "value": 2340
+ },
+ {
+ "code": "child_lock",
+ "dp_id": 41,
+ "value": false
+ }
+ ]
+ },
+ "thing_model": {
+ "services": []
+ }
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/local-dps.json b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/local-dps.json
new file mode 100644
index 0000000000..044914519d
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/local-dps.json
@@ -0,0 +1,18 @@
+{
+ "1": false,
+ "9": 0,
+ "18": 0,
+ "19": 0,
+ "20": 2268,
+ "21": 1,
+ "22": 566,
+ "23": 26471,
+ "24": 14621,
+ "25": 2840,
+ "26": 0,
+ "38": "memory",
+ "39": false,
+ "40": "relay",
+ "41": false,
+ "44": ""
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/manifest.js b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/manifest.js
new file mode 100644
index 0000000000..d8f7c45216
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/manifest.js
@@ -0,0 +1,32 @@
+module.exports = {
+ name: 'LSC Power Plug FR incl. Power meter',
+ convertDevice: {
+ input: './input-device.json',
+ expected: './expected-device.json',
+ },
+ pollCloud: {
+ device: './poll-device.json',
+ response: './cloud-status.json',
+ expectedEvents: './expected-cloud-events.json',
+ },
+ pollLocal: {
+ device: './poll-device.json',
+ dps: './local-dps.json',
+ expectedEvents: './expected-local-events.json',
+ expectedCloudRequests: 1,
+ },
+ localMapping: {
+ device: './poll-device.json',
+ expected: './expected-local-mapping.json',
+ },
+ setValueLocal: {
+ device: './poll-device.json',
+ featureExternalId: 'tuya:bf2be3c32ea4d8f561ujmu:switch_1',
+ inputValue: 1,
+ expectedLocalSet: {
+ dps: 1,
+ set: true,
+ },
+ expectedCloudRequests: 0,
+ },
+};
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/poll-device.json b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/poll-device.json
new file mode 100644
index 0000000000..7cca7f8253
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/poll-device.json
@@ -0,0 +1,92 @@
+{
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu",
+ "device_type": "smart-socket",
+ "properties": {
+ "properties": [
+ {
+ "code": "switch_1",
+ "dp_id": 1,
+ "value": false
+ },
+ {
+ "code": "add_ele",
+ "dp_id": 17,
+ "value": 1
+ },
+ {
+ "code": "cur_current",
+ "dp_id": 18,
+ "value": 0
+ },
+ {
+ "code": "cur_power",
+ "dp_id": 19,
+ "value": 0
+ },
+ {
+ "code": "cur_voltage",
+ "dp_id": 20,
+ "value": 2340
+ },
+ {
+ "code": "child_lock",
+ "dp_id": 41,
+ "value": false
+ }
+ ]
+ },
+ "params": [
+ { "name": "IP_ADDRESS", "value": "192.168.1.50" },
+ { "name": "LOCAL_KEY", "value": "local-key" },
+ { "name": "PROTOCOL_VERSION", "value": "3.5" },
+ { "name": "LOCAL_OVERRIDE", "value": true }
+ ],
+ "features": [
+ {
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:switch_1",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:switch_1",
+ "category": "switch",
+ "type": "binary",
+ "last_value": 1
+ },
+ {
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:child_lock",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:child_lock",
+ "category": "child-lock",
+ "type": "binary",
+ "last_value": 1
+ },
+ {
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:add_ele",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:add_ele",
+ "category": "switch",
+ "type": "energy",
+ "scale": 3,
+ "last_value": 0
+ },
+ {
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_current",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:cur_current",
+ "category": "switch",
+ "type": "current",
+ "scale": 0,
+ "last_value": 1
+ },
+ {
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_power",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:cur_power",
+ "category": "switch",
+ "type": "power",
+ "scale": 1,
+ "last_value": 1
+ },
+ {
+ "external_id": "tuya:bf2be3c32ea4d8f561ujmu:cur_voltage",
+ "selector": "tuya:bf2be3c32ea4d8f561ujmu:cur_voltage",
+ "category": "switch",
+ "type": "voltage",
+ "scale": 1,
+ "last_value": 1
+ }
+ ]
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-basic/cloud-status.json b/server/test/services/tuya/fixtures/devices/smart-socket-basic/cloud-status.json
new file mode 100644
index 0000000000..4a7806299b
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-basic/cloud-status.json
@@ -0,0 +1,6 @@
+{
+ "result": [
+ { "code": "switch_1", "value": true },
+ { "code": "cur_power", "value": 2245 }
+ ]
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-cloud-events.json b/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-cloud-events.json
new file mode 100644
index 0000000000..0cdff16742
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-cloud-events.json
@@ -0,0 +1,10 @@
+[
+ {
+ "device_feature_external_id": "tuya:socket-device-id:switch_1",
+ "state": 1
+ },
+ {
+ "device_feature_external_id": "tuya:socket-device-id:cur_power",
+ "state": 224.5
+ }
+]
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-device.json b/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-device.json
new file mode 100644
index 0000000000..8a78973348
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-device.json
@@ -0,0 +1,67 @@
+{
+ "name": "Kitchen Plug",
+ "external_id": "tuya:socket-device-id",
+ "selector": "tuya:socket-device-id",
+ "device_type": "smart-socket",
+ "model": "Mini Plug",
+ "product_id": "cya3zxfd38g4qp8d",
+ "product_key": "socket-key",
+ "online": true,
+ "poll_frequency": 10000,
+ "should_poll": true,
+ "params": {
+ "CLOUD_READ_STRATEGY": "legacy",
+ "DEVICE_ID": "socket-device-id",
+ "LOCAL_KEY": "local-key",
+ "IP_ADDRESS": "10.0.0.10",
+ "CLOUD_IP": "1.2.3.4",
+ "LOCAL_OVERRIDE": true,
+ "PROTOCOL_VERSION": "3.3",
+ "PRODUCT_ID": "cya3zxfd38g4qp8d",
+ "PRODUCT_KEY": "socket-key"
+ },
+ "tuya_mapping": {
+ "ignored_local_dps": ["9", "11", "21", "22", "23", "24", "25", "38", "39", "40", "42", "43", "44"],
+ "ignored_cloud_codes": [
+ "countdown",
+ "countdown_1",
+ "relay_status",
+ "overcharge_switch",
+ "light_mode",
+ "cycle_time",
+ "random_time",
+ "switch_inching",
+ "voltage_coe",
+ "electric_coe",
+ "power_coe",
+ "electricity_coe",
+ "test_bit"
+ ]
+ },
+ "features": [
+ {
+ "name": "switch_1",
+ "external_id": "tuya:socket-device-id:switch_1",
+ "selector": "tuya:socket-device-id:switch_1",
+ "category": "switch",
+ "type": "binary",
+ "read_only": false,
+ "has_feedback": false,
+ "min": 0,
+ "max": 1
+ },
+ {
+ "name": "cur_power",
+ "external_id": "tuya:socket-device-id:cur_power",
+ "selector": "tuya:socket-device-id:cur_power",
+ "category": "switch",
+ "type": "power",
+ "read_only": true,
+ "has_feedback": false,
+ "min": 0,
+ "max": 99999,
+ "unit": "watt",
+ "scale": 1
+ }
+ ]
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-local-events.json b/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-local-events.json
new file mode 100644
index 0000000000..d619525b70
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-local-events.json
@@ -0,0 +1,6 @@
+[
+ {
+ "device_feature_external_id": "tuya:socket-device-id:switch_1",
+ "state": 1
+ }
+]
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-local-mapping.json b/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-local-mapping.json
new file mode 100644
index 0000000000..3b55eb5d9b
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-local-mapping.json
@@ -0,0 +1,4 @@
+{
+ "switch_1": 1,
+ "cur_power": 19
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-basic/input-device.json b/server/test/services/tuya/fixtures/devices/smart-socket-basic/input-device.json
new file mode 100644
index 0000000000..971729493a
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-basic/input-device.json
@@ -0,0 +1,42 @@
+{
+ "id": "socket-device-id",
+ "name": "Kitchen Plug",
+ "product_name": "Mini Plug",
+ "product_id": "cya3zxfd38g4qp8d",
+ "product_key": "socket-key",
+ "local_key": "local-key",
+ "ip": "10.0.0.10",
+ "cloud_ip": "1.2.3.4",
+ "protocol_version": "3.3",
+ "local_override": true,
+ "online": true,
+ "specifications": {
+ "category": "cz",
+ "functions": [
+ {
+ "code": "switch_1",
+ "name": "Switch 1",
+ "type": "Boolean"
+ }
+ ],
+ "status": [
+ {
+ "code": "cur_power",
+ "name": "Current Power",
+ "type": "Integer",
+ "values": {
+ "min": 0,
+ "max": 99999,
+ "scale": 1,
+ "unit": "W"
+ }
+ }
+ ]
+ },
+ "properties": {
+ "properties": []
+ },
+ "thing_model": {
+ "services": []
+ }
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-basic/local-dps.json b/server/test/services/tuya/fixtures/devices/smart-socket-basic/local-dps.json
new file mode 100644
index 0000000000..faa5caa487
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-basic/local-dps.json
@@ -0,0 +1,4 @@
+{
+ "1": true,
+ "6": 2245
+}
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-basic/manifest.js b/server/test/services/tuya/fixtures/devices/smart-socket-basic/manifest.js
new file mode 100644
index 0000000000..972c44cfc0
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-basic/manifest.js
@@ -0,0 +1,32 @@
+module.exports = {
+ name: 'smart socket basic',
+ convertDevice: {
+ input: './input-device.json',
+ expected: './expected-device.json',
+ },
+ pollCloud: {
+ device: './poll-device.json',
+ response: './cloud-status.json',
+ expectedEvents: './expected-cloud-events.json',
+ },
+ pollLocal: {
+ device: './poll-device.json',
+ dps: './local-dps.json',
+ expectedEvents: './expected-local-events.json',
+ expectedCloudRequests: 1,
+ },
+ localMapping: {
+ device: './poll-device.json',
+ expected: './expected-local-mapping.json',
+ },
+ setValueLocal: {
+ device: './poll-device.json',
+ featureExternalId: 'tuya:socket-device-id:switch_1',
+ inputValue: 1,
+ expectedLocalSet: {
+ dps: 1,
+ set: true,
+ },
+ expectedCloudRequests: 0,
+ },
+};
diff --git a/server/test/services/tuya/fixtures/devices/smart-socket-basic/poll-device.json b/server/test/services/tuya/fixtures/devices/smart-socket-basic/poll-device.json
new file mode 100644
index 0000000000..2914f5663f
--- /dev/null
+++ b/server/test/services/tuya/fixtures/devices/smart-socket-basic/poll-device.json
@@ -0,0 +1,27 @@
+{
+ "external_id": "tuya:socket-device-id",
+ "device_type": "smart-socket",
+ "params": [
+ { "name": "IP_ADDRESS", "value": "10.0.0.10" },
+ { "name": "LOCAL_KEY", "value": "local-key" },
+ { "name": "PROTOCOL_VERSION", "value": "3.3" },
+ { "name": "LOCAL_OVERRIDE", "value": true }
+ ],
+ "features": [
+ {
+ "external_id": "tuya:socket-device-id:switch_1",
+ "selector": "tuya:socket-device-id:switch_1",
+ "category": "switch",
+ "type": "binary",
+ "last_value": 0
+ },
+ {
+ "external_id": "tuya:socket-device-id:cur_power",
+ "selector": "tuya:socket-device-id:cur_power",
+ "category": "switch",
+ "type": "power",
+ "scale": 1,
+ "last_value": 0
+ }
+ ]
+}
diff --git a/server/test/services/tuya/fixtures/fixtureHelper.js b/server/test/services/tuya/fixtures/fixtureHelper.js
new file mode 100644
index 0000000000..b3332b7002
--- /dev/null
+++ b/server/test/services/tuya/fixtures/fixtureHelper.js
@@ -0,0 +1,121 @@
+const fs = require('fs');
+const { createRequire } = require('module');
+const path = require('path');
+
+const FIXTURES_ROOT = path.join(__dirname, 'devices');
+const fixtureRequire = createRequire(__filename);
+
+const sortByKey = (value) => {
+ if (Array.isArray(value)) {
+ return value.map(sortByKey).sort((left, right) => {
+ const leftKey = JSON.stringify(left);
+ const rightKey = JSON.stringify(right);
+ return leftKey.localeCompare(rightKey);
+ });
+ }
+ if (value && typeof value === 'object') {
+ return Object.keys(value)
+ .sort()
+ .reduce((accumulator, key) => {
+ accumulator[key] = sortByKey(value[key]);
+ return accumulator;
+ }, {});
+ }
+ return value;
+};
+
+const loadJson = (filename) => JSON.parse(fs.readFileSync(filename, 'utf8'));
+
+const omitUndefined = (value) =>
+ Object.keys(value).reduce((accumulator, key) => {
+ if (value[key] !== undefined) {
+ accumulator[key] = value[key];
+ }
+ return accumulator;
+ }, {});
+
+const normalizeParams = (params) =>
+ (Array.isArray(params) ? params : []).reduce((accumulator, param) => {
+ accumulator[param.name] = param.value;
+ return accumulator;
+ }, {});
+
+const normalizeFeatures = (features) =>
+ sortByKey(
+ (Array.isArray(features) ? features : []).map((feature) => {
+ const normalized = omitUndefined({
+ name: feature.name,
+ external_id: feature.external_id,
+ selector: feature.selector,
+ category: feature.category,
+ type: feature.type,
+ read_only: feature.read_only,
+ has_feedback: feature.has_feedback,
+ min: feature.min,
+ max: feature.max,
+ });
+
+ if (feature.unit !== undefined) {
+ normalized.unit = feature.unit;
+ }
+ if (feature.scale !== undefined) {
+ normalized.scale = feature.scale;
+ }
+ return normalized;
+ }),
+ );
+
+const normalizeConvertedDevice = (device) =>
+ omitUndefined({
+ name: device.name,
+ external_id: device.external_id,
+ selector: device.selector,
+ device_type: device.device_type,
+ model: device.model,
+ product_id: device.product_id,
+ product_key: device.product_key,
+ online: device.online,
+ poll_frequency: device.poll_frequency,
+ should_poll: device.should_poll,
+ params: normalizeParams(device.params),
+ tuya_mapping: sortByKey(device.tuya_mapping),
+ features: normalizeFeatures(device.features),
+ });
+
+const normalizeEvents = (calls) =>
+ sortByKey(
+ calls.map((call) => ({
+ device_feature_external_id: call.args[1].device_feature_external_id,
+ state: call.args[1].state,
+ })),
+ );
+
+const loadFixtureCases = (sectionName) =>
+ fs
+ .readdirSync(FIXTURES_ROOT)
+ .sort()
+ .filter((directoryName) => fs.statSync(path.join(FIXTURES_ROOT, directoryName)).isDirectory())
+ .map((directoryName) => {
+ const fixtureDirectory = path.join(FIXTURES_ROOT, directoryName);
+ const manifest = fixtureRequire(path.join(fixtureDirectory, 'manifest.js'));
+ if (!manifest[sectionName]) {
+ return null;
+ }
+ return {
+ directoryName,
+ fixtureDirectory,
+ manifest,
+ load(relativePath) {
+ return loadJson(path.join(fixtureDirectory, relativePath));
+ },
+ };
+ })
+ .filter(Boolean);
+
+module.exports = {
+ loadFixtureCases,
+ normalizeConvertedDevice,
+ normalizeEvents,
+ omitUndefined,
+ sortByKey,
+};
diff --git a/server/test/services/tuya/index.test.js b/server/test/services/tuya/index.test.js
index bf08d1e33b..18851c3855 100644
--- a/server/test/services/tuya/index.test.js
+++ b/server/test/services/tuya/index.test.js
@@ -6,7 +6,9 @@ const { STATUS } = require('../../../services/tuya/lib/utils/tuya.constants');
const { assert, fake } = sinon;
const TuyaHandlerMock = sinon.stub();
-TuyaHandlerMock.prototype.init = fake.returns(null);
+TuyaHandlerMock.prototype.init = fake(function init() {
+ this.status = STATUS.CONNECTED;
+});
TuyaHandlerMock.prototype.loadDevices = fake.returns(null);
TuyaHandlerMock.prototype.disconnect = fake.returns(null);
@@ -16,14 +18,26 @@ const gladys = {};
const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
describe('TuyaService', () => {
- const tuyaService = TuyaService(gladys, serviceId);
+ let tuyaService;
+ let intervalCallback;
+ let setIntervalStub;
+ let clearIntervalStub;
beforeEach(() => {
- sinon.reset();
+ sinon.resetHistory();
+ intervalCallback = null;
+ setIntervalStub = sinon.stub(global, 'setInterval').callsFake((cb) => {
+ intervalCallback = cb;
+ return 123;
+ });
+ clearIntervalStub = sinon.stub(global, 'clearInterval');
+ tuyaService = TuyaService(gladys, serviceId);
});
afterEach(() => {
- sinon.reset();
+ setIntervalStub.restore();
+ clearIntervalStub.restore();
+ sinon.resetHistory();
});
it('should start service', async () => {
@@ -34,9 +48,10 @@ describe('TuyaService', () => {
});
it('should stop service', async () => {
- tuyaService.stop();
- assert.notCalled(tuyaService.device.init);
+ await tuyaService.start();
+ await tuyaService.stop();
assert.calledOnce(tuyaService.device.disconnect);
+ assert.calledOnce(clearIntervalStub);
});
it('isUsed: should return false, service not used', async () => {
@@ -50,4 +65,148 @@ describe('TuyaService', () => {
const used = await tuyaService.isUsed();
expect(used).to.equal(true);
});
+
+ it('should attempt auto-reconnect when disconnected', async () => {
+ tuyaService.device.getStatus = fake.resolves({ configured: true, manual_disconnect: false });
+ tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' });
+ tuyaService.device.connect = fake.resolves();
+
+ await tuyaService.start();
+ tuyaService.device.status = STATUS.ERROR;
+ tuyaService.device.autoReconnectAllowed = true;
+
+ await intervalCallback();
+
+ assert.calledOnce(tuyaService.device.getStatus);
+ assert.calledOnce(tuyaService.device.getConfiguration);
+ assert.calledOnce(tuyaService.device.connect);
+ });
+
+ it('should not auto-reconnect when not configured or manually disconnected', async () => {
+ tuyaService.device.getStatus = fake.resolves({ configured: false, manual_disconnect: true });
+ tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' });
+ tuyaService.device.connect = fake.resolves();
+
+ await tuyaService.start();
+ tuyaService.device.status = STATUS.ERROR;
+ tuyaService.device.autoReconnectAllowed = true;
+
+ await intervalCallback();
+
+ assert.calledOnce(tuyaService.device.getStatus);
+ assert.notCalled(tuyaService.device.getConfiguration);
+ assert.notCalled(tuyaService.device.connect);
+ });
+
+ it('should not auto-reconnect when autoReconnectAllowed is false', async () => {
+ tuyaService.device.getStatus = fake.resolves({ configured: true, manual_disconnect: false });
+ tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' });
+ tuyaService.device.connect = fake.resolves();
+
+ await tuyaService.start();
+ tuyaService.device.status = STATUS.ERROR;
+ tuyaService.device.autoReconnectAllowed = false;
+
+ await intervalCallback();
+
+ assert.notCalled(tuyaService.device.getStatus);
+ assert.notCalled(tuyaService.device.getConfiguration);
+ assert.notCalled(tuyaService.device.connect);
+ });
+
+ it('should not auto-reconnect when already connecting', async () => {
+ tuyaService.device.getStatus = fake.resolves({ configured: true, manual_disconnect: false });
+ tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' });
+ tuyaService.device.connect = fake.resolves();
+
+ await tuyaService.start();
+ tuyaService.device.status = STATUS.CONNECTING;
+ tuyaService.device.autoReconnectAllowed = true;
+
+ await intervalCallback();
+
+ assert.calledOnce(tuyaService.device.getStatus);
+ assert.notCalled(tuyaService.device.getConfiguration);
+ assert.notCalled(tuyaService.device.connect);
+ });
+
+ it('should schedule quick reconnects on start when disconnected and allowed', async () => {
+ tuyaService.device.init = fake(function init() {
+ this.status = STATUS.ERROR;
+ this.autoReconnectAllowed = true;
+ });
+ tuyaService.device.getStatus = fake.resolves({ configured: false, manual_disconnect: false });
+
+ await tuyaService.start();
+
+ assert.calledOnce(tuyaService.device.getStatus);
+ assert.notCalled(tuyaService.device.loadDevices);
+ });
+
+ it('should skip quick reconnect when already in progress', async () => {
+ let resolveStatus;
+ const pendingStatus = new Promise((resolve) => {
+ resolveStatus = resolve;
+ });
+
+ tuyaService.device.getStatus = fake.returns(pendingStatus);
+ tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' });
+ tuyaService.device.connect = fake.resolves();
+
+ await tuyaService.start();
+ tuyaService.device.status = STATUS.ERROR;
+ tuyaService.device.autoReconnectAllowed = true;
+
+ const firstCall = intervalCallback();
+ await intervalCallback();
+
+ assert.calledOnce(tuyaService.device.getStatus);
+
+ resolveStatus({ configured: false, manual_disconnect: false });
+ await firstCall;
+ });
+
+ it('should clear pending quick reconnect timeouts', async () => {
+ const setTimeoutStub = sinon.stub(global, 'setTimeout').callsFake(() => 456);
+ const clearTimeoutStub = sinon.stub(global, 'clearTimeout');
+
+ try {
+ tuyaService.device.init = fake(function init() {
+ this.status = STATUS.CONNECTED;
+ this.autoReconnectAllowed = true;
+ });
+ tuyaService.device.getStatus = fake.resolves({ configured: true, manual_disconnect: false });
+ tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' });
+ tuyaService.device.connect = fake.resolves();
+
+ await tuyaService.start();
+ tuyaService.device.status = STATUS.ERROR;
+ tuyaService.device.autoReconnectAllowed = true;
+
+ await intervalCallback();
+ await tuyaService.stop();
+
+ assert.calledOnce(setTimeoutStub);
+ assert.calledWith(clearTimeoutStub, 456);
+ } finally {
+ setTimeoutStub.restore();
+ clearTimeoutStub.restore();
+ }
+ });
+
+ it('should handle auto-reconnect errors', async () => {
+ tuyaService.device.getStatus = fake.rejects(new Error('status failure'));
+ tuyaService.device.getConfiguration = fake.resolves({ config: 'ok' });
+ tuyaService.device.connect = fake.resolves();
+
+ await tuyaService.start();
+ tuyaService.device.status = STATUS.ERROR;
+ tuyaService.device.autoReconnectAllowed = true;
+
+ await intervalCallback();
+
+ assert.calledOnce(tuyaService.device.getStatus);
+ assert.notCalled(tuyaService.device.getConfiguration);
+ assert.notCalled(tuyaService.device.connect);
+ });
});
diff --git a/server/test/services/tuya/lib/controllers/tuya.controller.test.js b/server/test/services/tuya/lib/controllers/tuya.controller.test.js
index ee140346a4..9ddb962144 100644
--- a/server/test/services/tuya/lib/controllers/tuya.controller.test.js
+++ b/server/test/services/tuya/lib/controllers/tuya.controller.test.js
@@ -1,18 +1,32 @@
const sinon = require('sinon');
+const { expect } = require('chai');
const TuyaController = require('../../../../../services/tuya/api/tuya.controller');
const { assert, fake } = sinon;
const tuyaManager = {
discoverDevices: fake.resolves([]),
+ localPoll: fake.resolves({ dps: { 1: true } }),
+ localScan: fake.resolves({ devices: { device1: { ip: '1.1.1.1', version: '3.3' } }, portErrors: {} }),
+ getStatus: fake.resolves({ status: 'connected' }),
+ manualDisconnect: fake.resolves(),
+ discoveredDevices: [
+ {
+ external_id: 'tuya:device1',
+ params: [],
+ },
+ ],
};
+const defaultLocalScan = tuyaManager.localScan;
describe('TuyaController GET /api/v1/service/tuya/discover', () => {
let controller;
beforeEach(() => {
controller = TuyaController(tuyaManager);
- sinon.reset();
+ sinon.resetHistory();
+ tuyaManager.localScan = fake.resolves({ devices: { device1: { ip: '1.1.1.1', version: '3.3' } }, portErrors: {} });
+ tuyaManager.discoveredDevices = [{ external_id: 'tuya:device1', params: [] }];
});
it('should return discovered devices', async () => {
@@ -26,3 +40,164 @@ describe('TuyaController GET /api/v1/service/tuya/discover', () => {
assert.calledOnce(res.json);
});
});
+
+describe('TuyaController POST /api/v1/service/tuya/local-poll', () => {
+ let controller;
+
+ beforeEach(() => {
+ controller = TuyaController(tuyaManager);
+ sinon.resetHistory();
+ tuyaManager.localScan = fake.resolves({ devices: { device1: { ip: '1.1.1.1', version: '3.3' } }, portErrors: {} });
+ tuyaManager.discoveredDevices = [{ external_id: 'tuya:device1', params: [] }];
+ });
+
+ it('should return local poll result', async () => {
+ const req = {
+ body: { deviceId: 'device1', ip: '1.1.1.1', localKey: 'key', protocolVersion: '3.3' },
+ };
+ const res = {
+ json: fake.returns([]),
+ };
+
+ await controller['post /api/v1/service/tuya/local-poll'].controller(req, res);
+ assert.calledOnce(tuyaManager.localPoll);
+ assert.calledOnce(res.json);
+ });
+
+ it('should return local poll result without updating device', async () => {
+ const req = {
+ body: { deviceId: 'unknown', ip: '1.1.1.1', localKey: 'key', protocolVersion: '3.3' },
+ };
+ const res = {
+ json: fake.returns([]),
+ };
+ tuyaManager.discoveredDevices = [{ external_id: 'tuya:device1', params: [] }];
+
+ await controller['post /api/v1/service/tuya/local-poll'].controller(req, res);
+ assert.calledOnce(tuyaManager.localPoll);
+ assert.calledWith(res.json, { dps: { 1: true } });
+ });
+
+ it('should update existing params when device is found', async () => {
+ const req = {
+ body: { deviceId: 'device1', ip: '2.2.2.2', protocolVersion: '3.3' },
+ };
+ const res = {
+ json: fake.returns([]),
+ };
+ tuyaManager.discoveredDevices = [
+ {
+ external_id: 'tuya:device1',
+ product_id: 'pid',
+ product_key: 'pkey',
+ params: [{ name: 'IP_ADDRESS', value: '1.1.1.1' }],
+ },
+ ];
+
+ await controller['post /api/v1/service/tuya/local-poll'].controller(req, res);
+
+ const updated = tuyaManager.discoveredDevices[0];
+ const ipParam = updated.params.find((param) => param.name === 'IP_ADDRESS');
+ const localKeyParam = updated.params.find((param) => param.name === 'LOCAL_KEY');
+
+ expect(ipParam.value).to.equal('2.2.2.2');
+ expect(localKeyParam).to.equal(undefined);
+ assert.calledOnce(res.json);
+ });
+});
+
+describe('TuyaController POST /api/v1/service/tuya/local-scan', () => {
+ let controller;
+
+ beforeEach(() => {
+ controller = TuyaController(tuyaManager);
+ sinon.resetHistory();
+ tuyaManager.localScan = fake.resolves({ devices: { device1: { ip: '1.1.1.1', version: '3.3' } }, portErrors: {} });
+ tuyaManager.discoveredDevices = [{ external_id: 'tuya:device1', params: [] }];
+ });
+
+ afterEach(() => {
+ tuyaManager.localScan = defaultLocalScan;
+ });
+
+ it('should run local scan and return devices', async () => {
+ const req = { body: { timeoutSeconds: 1 } };
+ const res = {
+ json: fake.returns([]),
+ };
+
+ await controller['post /api/v1/service/tuya/local-scan'].controller(req, res);
+ assert.calledOnce(tuyaManager.localScan);
+ assert.calledOnce(res.json);
+ });
+
+ it('should return local devices even without discovered devices', async () => {
+ const req = { body: { timeoutSeconds: 1 } };
+ const res = {
+ json: fake.returns([]),
+ };
+ tuyaManager.discoveredDevices = null;
+
+ await controller['post /api/v1/service/tuya/local-scan'].controller(req, res);
+ assert.calledOnce(tuyaManager.localScan);
+ assert.calledOnce(res.json);
+ const payload = res.json.firstCall.args[0];
+ expect(payload.devices).to.have.length(1);
+ expect(payload.devices[0].external_id).to.equal('tuya:device1');
+ expect(payload.local_devices).to.deep.equal({ device1: { ip: '1.1.1.1', version: '3.3' } });
+ expect(payload.port_errors).to.deep.equal({});
+ });
+
+ it('should keep devices unchanged when local info is missing', async () => {
+ const req = { body: { timeoutSeconds: 1 } };
+ const res = {
+ json: fake.returns([]),
+ };
+ tuyaManager.localScan = fake.resolves({ devices: {}, portErrors: {} });
+ tuyaManager.discoveredDevices = [{ external_id: 'tuya:device1', params: [] }];
+
+ await controller['post /api/v1/service/tuya/local-scan'].controller(req, res);
+ assert.calledOnce(tuyaManager.localScan);
+ assert.calledWith(res.json, {
+ devices: [{ external_id: 'tuya:device1', params: [] }],
+ local_devices: {},
+ port_errors: {},
+ });
+ });
+});
+
+describe('TuyaController GET /api/v1/service/tuya/status', () => {
+ let controller;
+
+ beforeEach(() => {
+ controller = TuyaController(tuyaManager);
+ sinon.resetHistory();
+ });
+
+ it('should return status', async () => {
+ const req = {};
+ const res = { json: fake.returns([]) };
+
+ await controller['get /api/v1/service/tuya/status'].controller(req, res);
+ assert.calledOnce(tuyaManager.getStatus);
+ assert.calledWith(res.json, { status: 'connected' });
+ });
+});
+
+describe('TuyaController POST /api/v1/service/tuya/disconnect', () => {
+ let controller;
+
+ beforeEach(() => {
+ controller = TuyaController(tuyaManager);
+ sinon.resetHistory();
+ });
+
+ it('should disconnect', async () => {
+ const req = {};
+ const res = { json: fake.returns([]) };
+
+ await controller['post /api/v1/service/tuya/disconnect'].controller(req, res);
+ assert.calledOnce(tuyaManager.manualDisconnect);
+ assert.calledWith(res.json, { success: true });
+ });
+});
diff --git a/server/test/services/tuya/lib/device/feature/tuya.convertFeature.test.js b/server/test/services/tuya/lib/device/feature/tuya.convertFeature.test.js
index bd39be94ab..7ba5a2593d 100644
--- a/server/test/services/tuya/lib/device/feature/tuya.convertFeature.test.js
+++ b/server/test/services/tuya/lib/device/feature/tuya.convertFeature.test.js
@@ -1,6 +1,7 @@
const { expect } = require('chai');
const { convertFeature } = require('../../../../../../services/tuya/lib/device/tuya.convertFeature');
+const { DEVICE_TYPES } = require('../../../../../../services/tuya/lib/mappings');
describe('Tuya convert feature', () => {
it('should return undefined when code not exist', () => {
@@ -25,7 +26,7 @@ describe('Tuya convert feature', () => {
has_feedback: false,
max: 1000,
min: 100,
- name: 'name',
+ name: 'switch_1',
read_only: false,
selector: 'externalId:switch_1',
type: 'binary',
@@ -49,10 +50,71 @@ describe('Tuya convert feature', () => {
has_feedback: false,
max: 1,
min: 0,
- name: 'name',
+ name: 'switch_1',
read_only: false,
selector: 'externalId:switch_1',
type: 'binary',
});
});
+
+ it('should ignore cloud codes flagged in mapping', () => {
+ const result = convertFeature(
+ {
+ code: 'countdown',
+ type: 'Integer',
+ name: 'countdown',
+ readOnly: true,
+ values: '{}',
+ },
+ 'externalId',
+ {
+ deviceType: DEVICE_TYPES.SMART_SOCKET,
+ },
+ );
+
+ expect(result).to.equal(undefined);
+ });
+
+ it('should support object values payload', () => {
+ const result = convertFeature(
+ {
+ code: 'switch_1',
+ type: 'Boolean',
+ name: 'name',
+ readOnly: false,
+ values: { min: 2, max: 8 },
+ },
+ 'externalId',
+ );
+
+ expect(result).to.deep.eq({
+ category: 'switch',
+ external_id: 'externalId:switch_1',
+ has_feedback: false,
+ max: 8,
+ min: 2,
+ name: 'switch_1',
+ read_only: false,
+ selector: 'externalId:switch_1',
+ type: 'binary',
+ });
+ });
+
+ it('should keep scale from values payload', () => {
+ const result = convertFeature(
+ {
+ code: 'cur_power',
+ type: 'Integer',
+ name: 'power',
+ readOnly: true,
+ values: { min: 0, max: 99999, scale: 1 },
+ },
+ 'externalId',
+ {
+ deviceType: DEVICE_TYPES.SMART_SOCKET,
+ },
+ );
+
+ expect(result.scale).to.equal(1);
+ });
});
diff --git a/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js b/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js
index 9412c4720a..22992bde11 100644
--- a/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js
+++ b/server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js
@@ -24,6 +24,10 @@ describe('Tuya device mapping', () => {
const result = writeValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.BINARY](1);
expect(result).to.eq(true);
});
+ it('child lock binary', () => {
+ const result = writeValues[DEVICE_FEATURE_CATEGORIES.CHILD_LOCK][DEVICE_FEATURE_TYPES.CHILD_LOCK.BINARY](1);
+ expect(result).to.eq(true);
+ });
describe('curtain state', () => {
it('open', () => {
const result = writeValues[DEVICE_FEATURE_CATEGORIES.CURTAIN][DEVICE_FEATURE_TYPES.CURTAIN.STATE](
@@ -55,6 +59,10 @@ describe('Tuya device mapping', () => {
const result = readValues[DEVICE_FEATURE_CATEGORIES.LIGHT][DEVICE_FEATURE_TYPES.LIGHT.BINARY](true);
expect(result).to.eq(1);
});
+ it('light binary string', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.LIGHT][DEVICE_FEATURE_TYPES.LIGHT.BINARY]('true');
+ expect(result).to.eq(1);
+ });
it('light brightness', () => {
const result = readValues[DEVICE_FEATURE_CATEGORIES.LIGHT][DEVICE_FEATURE_TYPES.LIGHT.BRIGHTNESS](50);
expect(result).to.eq(50);
@@ -74,22 +82,134 @@ describe('Tuya device mapping', () => {
const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.BINARY](true);
expect(result).to.eq(1);
});
+ it('switch number', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.BINARY](1);
+ expect(result).to.eq(1);
+ });
+ it('switch string false', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.BINARY]('false');
+ expect(result).to.eq(0);
+ });
+ it('switch string on', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.BINARY]('ON');
+ expect(result).to.eq(1);
+ });
+ it('child lock', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.CHILD_LOCK][DEVICE_FEATURE_TYPES.CHILD_LOCK.BINARY]('true');
+ expect(result).to.eq(1);
+ });
it('energy', () => {
const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.ENERGY]('30');
expect(result).to.eq(0.3);
});
+ it('energy with explicit scale', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.ENERGY]('30', {
+ scale: 1,
+ });
+ expect(result).to.eq(3);
+ });
it('current', () => {
const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.CURRENT]('20');
expect(result).to.eq(20);
});
+ it('current with explicit scale', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.CURRENT]('205', {
+ scale: 1,
+ });
+ expect(result).to.eq(20.5);
+ });
it('power', () => {
const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.POWER]('2245');
expect(result).to.eq(224.5);
});
+ it('power with explicit scale', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.POWER]('2245', {
+ scale: 2,
+ });
+ expect(result).to.eq(22.45);
+ });
it('voltage', () => {
const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.VOLTAGE]('120');
expect(result).to.eq(12.0);
});
+ it('voltage with explicit scale', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.VOLTAGE]('2352', {
+ scale: 1,
+ });
+ expect(result).to.eq(235.2);
+ });
+ it('switch power invalid value returns NaN', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.SWITCH][DEVICE_FEATURE_TYPES.SWITCH.POWER]('not-a-number');
+ expect(Number.isNaN(result)).to.equal(true);
+ });
+ });
+ describe('energy sensor', () => {
+ it('energy sensor power invalid value returns NaN', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR][DEVICE_FEATURE_TYPES.ENERGY_SENSOR.POWER](
+ 'not-a-number',
+ );
+ expect(Number.isNaN(result)).to.equal(true);
+ });
+ it('power uses default scale when feature scale is missing', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR][DEVICE_FEATURE_TYPES.ENERGY_SENSOR.POWER](
+ '1764',
+ );
+ expect(result).to.eq(176.4);
+ });
+ it('power with scale', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR][
+ DEVICE_FEATURE_TYPES.ENERGY_SENSOR.POWER
+ ]('706', { scale: 1 });
+ expect(result).to.eq(70.6);
+ });
+ it('energy uses default scale when feature scale is missing', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR][DEVICE_FEATURE_TYPES.ENERGY_SENSOR.ENERGY](
+ '158068',
+ );
+ expect(result).to.eq(1580.68);
+ });
+ it('energy with scale', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR][
+ DEVICE_FEATURE_TYPES.ENERGY_SENSOR.ENERGY
+ ]('149241', { scale: 2 });
+ expect(result).to.eq(1492.41);
+ });
+ it('export index uses default scale when feature scale is missing', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR][
+ DEVICE_FEATURE_TYPES.ENERGY_SENSOR.EXPORT_INDEX
+ ]('48401');
+ expect(result).to.eq(484.01);
+ });
+ it('export index with scale', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR][
+ DEVICE_FEATURE_TYPES.ENERGY_SENSOR.EXPORT_INDEX
+ ]('43222', { scale: 2 });
+ expect(result).to.eq(432.22);
+ });
+ it('voltage uses default scale when feature scale is missing', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR][DEVICE_FEATURE_TYPES.ENERGY_SENSOR.VOLTAGE](
+ '2365',
+ );
+ expect(result).to.eq(236.5);
+ });
+ it('voltage with scale', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR][
+ DEVICE_FEATURE_TYPES.ENERGY_SENSOR.VOLTAGE
+ ]('2301', { scale: 1 });
+ expect(result).to.eq(230.1);
+ });
+ it('current uses default scale when feature scale is missing', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR][DEVICE_FEATURE_TYPES.ENERGY_SENSOR.CURRENT](
+ '1105',
+ );
+ expect(result).to.eq(1105);
+ });
+ it('current without scale', () => {
+ const result = readValues[DEVICE_FEATURE_CATEGORIES.ENERGY_SENSOR][
+ DEVICE_FEATURE_TYPES.ENERGY_SENSOR.CURRENT
+ ]('123', { scale: 0 });
+ expect(result).to.eq(123);
+ });
});
describe('curtain state', () => {
it('open', () => {
diff --git a/server/test/services/tuya/lib/device/tuya.convertDevice.fixtures.test.js b/server/test/services/tuya/lib/device/tuya.convertDevice.fixtures.test.js
new file mode 100644
index 0000000000..1d3814ed60
--- /dev/null
+++ b/server/test/services/tuya/lib/device/tuya.convertDevice.fixtures.test.js
@@ -0,0 +1,99 @@
+const { expect } = require('chai');
+
+const { convertDevice } = require('../../../../../services/tuya/lib/device/tuya.convertDevice');
+const { loadFixtureCases, normalizeConvertedDevice, sortByKey } = require('../../fixtures/fixtureHelper');
+
+const cloneDeep = (value) => JSON.parse(JSON.stringify(value));
+
+const getFeatureCode = (feature) => {
+ if (!feature || !feature.external_id) {
+ return null;
+ }
+ return String(feature.external_id)
+ .split(':')
+ .pop();
+};
+
+const removeCodeFromInput = (input, code) => {
+ if (!input || !code) {
+ return 0;
+ }
+
+ let removed = 0;
+ const filterEntries = (entries) =>
+ (Array.isArray(entries) ? entries : []).filter((entry) => {
+ const keep = entry && entry.code !== code;
+ if (!keep) {
+ removed += 1;
+ }
+ return keep;
+ });
+
+ if (input.specifications && typeof input.specifications === 'object') {
+ ['functions', 'status', 'properties'].forEach((key) => {
+ if (Array.isArray(input.specifications[key])) {
+ input.specifications[key] = filterEntries(input.specifications[key]);
+ }
+ });
+ }
+
+ if (input.properties && Array.isArray(input.properties.properties)) {
+ input.properties.properties = filterEntries(input.properties.properties);
+ }
+
+ if (input.thing_model && Array.isArray(input.thing_model.services)) {
+ input.thing_model.services = input.thing_model.services.map((service) => ({
+ ...service,
+ properties: filterEntries(service && service.properties),
+ }));
+ }
+
+ return removed;
+};
+
+describe('tuya.convertDevice fixtures', () => {
+ const fixtureCases = loadFixtureCases('convertDevice');
+
+ it('should load at least one convertDevice fixture case', () => {
+ expect(fixtureCases.length).to.be.greaterThan(0);
+ });
+
+ fixtureCases.forEach((fixtureCase) => {
+ it(`should convert ${fixtureCase.manifest.name} from fixture`, () => {
+ const { input, expected } = fixtureCase.manifest.convertDevice;
+ const device = convertDevice.call({ serviceId: 'service-id' }, fixtureCase.load(input));
+
+ expect(normalizeConvertedDevice(device)).to.deep.equal(sortByKey(fixtureCase.load(expected)));
+ });
+
+ it(`should drop a supported feature when its source code is removed for ${fixtureCase.manifest.name}`, () => {
+ const { input } = fixtureCase.manifest.convertDevice;
+ const sourceInput = fixtureCase.load(input);
+ const converted = convertDevice.call({ serviceId: 'service-id' }, sourceInput);
+ const removableFeature = (Array.isArray(converted.features) ? converted.features : [])
+ .map((feature) => ({
+ feature,
+ code: getFeatureCode(feature),
+ }))
+ .find(({ code }) => {
+ const degradedInput = cloneDeep(sourceInput);
+ return removeCodeFromInput(degradedInput, code) > 0;
+ });
+
+ expect(removableFeature, 'fixture should expose at least one removable supported feature').to.not.equal(
+ undefined,
+ );
+
+ const degradedInput = cloneDeep(sourceInput);
+ const removedCount = removeCodeFromInput(degradedInput, removableFeature.code);
+ expect(removedCount).to.be.greaterThan(0);
+
+ const degradedDevice = convertDevice.call({ serviceId: 'service-id' }, degradedInput);
+ const degradedCodes = new Set(
+ (Array.isArray(degradedDevice.features) ? degradedDevice.features : []).map(getFeatureCode),
+ );
+
+ expect(degradedCodes.has(removableFeature.code)).to.equal(false);
+ });
+ });
+});
diff --git a/server/test/services/tuya/lib/device/tuya.convertDevice.test.js b/server/test/services/tuya/lib/device/tuya.convertDevice.test.js
new file mode 100644
index 0000000000..600da109da
--- /dev/null
+++ b/server/test/services/tuya/lib/device/tuya.convertDevice.test.js
@@ -0,0 +1,313 @@
+const { expect } = require('chai');
+const sinon = require('sinon');
+
+const { convertDevice } = require('../../../../../services/tuya/lib/device/tuya.convertDevice');
+const { DEVICE_PARAM_NAME } = require('../../../../../services/tuya/lib/utils/tuya.constants');
+const { DEVICE_TYPES } = require('../../../../../services/tuya/lib/mappings');
+const { DEVICE_POLL_FREQUENCIES } = require('../../../../../utils/constants');
+const logger = require('../../../../../utils/logger');
+
+describe('tuya.convertDevice', () => {
+ it('should map params and features with optional fields', () => {
+ const tuyaDevice = {
+ id: 'device-id',
+ name: 'Device',
+ product_name: 'Model',
+ product_id: 'product-id',
+ product_key: 'product-key',
+ local_key: 'local-key',
+ ip: '1.1.1.1',
+ cloud_ip: '2.2.2.2',
+ protocol_version: '3.3',
+ local_override: ' TRUE ',
+ is_online: true,
+ properties: { properties: [{ code: 'foo', value: 'bar' }] },
+ thing_model: { services: [] },
+ specifications: {
+ category: 'cz',
+ functions: [{ code: 'switch_1', name: 'Switch', type: 'Boolean' }],
+ status: [{ code: 'cur_power', name: 'Power', type: 'Integer' }],
+ },
+ };
+
+ const device = convertDevice.call({ serviceId: 'service-id' }, tuyaDevice);
+
+ expect(device.product_id).to.equal('product-id');
+ expect(device.product_key).to.equal('product-key');
+ expect(device.device_type).to.equal(DEVICE_TYPES.SMART_SOCKET);
+ expect(device.online).to.equal(true);
+ expect(device.features.length).to.equal(2);
+ expect(device.properties).to.deep.equal({ properties: [{ code: 'foo', value: 'bar' }] });
+ expect(device.thing_model).to.deep.equal({ services: [] });
+ expect(device.specifications).to.deep.equal({
+ category: 'cz',
+ functions: [{ code: 'switch_1', name: 'Switch', type: 'Boolean' }],
+ status: [{ code: 'cur_power', name: 'Power', type: 'Integer' }],
+ });
+ expect(device.tuya_mapping).to.be.an('object');
+ expect(device.tuya_mapping.ignored_local_dps).to.be.an('array');
+ expect(device.tuya_mapping.ignored_cloud_codes).to.be.an('array');
+ expect(device.poll_frequency).to.equal(DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS);
+
+ const params = device.params.reduce((acc, param) => {
+ acc[param.name] = param.value;
+ return acc;
+ }, {});
+
+ expect(params[DEVICE_PARAM_NAME.DEVICE_ID]).to.equal('device-id');
+ expect(params[DEVICE_PARAM_NAME.LOCAL_KEY]).to.equal('local-key');
+ expect(params[DEVICE_PARAM_NAME.IP_ADDRESS]).to.equal('1.1.1.1');
+ expect(params[DEVICE_PARAM_NAME.CLOUD_IP]).to.equal('2.2.2.2');
+ expect(params[DEVICE_PARAM_NAME.PROTOCOL_VERSION]).to.equal('3.3');
+ expect(params[DEVICE_PARAM_NAME.LOCAL_OVERRIDE]).to.equal(true);
+ expect(params[DEVICE_PARAM_NAME.PRODUCT_ID]).to.equal('product-id');
+ expect(params[DEVICE_PARAM_NAME.PRODUCT_KEY]).to.equal('product-key');
+ expect(params[DEVICE_PARAM_NAME.CLOUD_READ_STRATEGY]).to.equal('legacy');
+ });
+
+ it('should handle missing optional fields', () => {
+ const tuyaDevice = {
+ id: 'device-id',
+ name: 'Device',
+ model: 'Model',
+ online: false,
+ specifications: {},
+ };
+
+ const device = convertDevice.call({ serviceId: 'service-id' }, tuyaDevice);
+
+ expect(device.product_id).to.equal(undefined);
+ expect(device.product_key).to.equal(undefined);
+ expect(device.device_type).to.equal(DEVICE_TYPES.UNKNOWN);
+ expect(device.online).to.equal(false);
+ expect(device.features.length).to.equal(0);
+ expect(device.specifications).to.deep.equal({});
+ expect(device.poll_frequency).to.equal(DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS);
+ });
+
+ it('should build features from thing model when specifications are empty', () => {
+ const tuyaDevice = {
+ id: 'device-id',
+ name: 'Wifi Plug Mini',
+ model: 'Wifi Plug Mini',
+ product_id: 'cya3zxfd38g4qp8d',
+ local_override: true,
+ thing_model: {
+ services: [
+ {
+ properties: [
+ {
+ code: 'switch_1',
+ name: 'Switch 1',
+ accessMode: 'rw',
+ typeSpec: { type: 'bool' },
+ },
+ {
+ code: 'cur_power',
+ name: 'Current Power',
+ accessMode: 'ro',
+ typeSpec: { min: 0, max: 99999, scale: 1, unit: 'W' },
+ },
+ ],
+ },
+ ],
+ },
+ specifications: {},
+ };
+
+ const device = convertDevice.call({ serviceId: 'service-id' }, tuyaDevice);
+
+ expect(device.device_type).to.equal(DEVICE_TYPES.SMART_SOCKET);
+ expect(device.features.map((feature) => feature.external_id)).to.have.members([
+ 'tuya:device-id:switch_1',
+ 'tuya:device-id:cur_power',
+ ]);
+ expect(device.features.find((feature) => feature.external_id === 'tuya:device-id:cur_power').scale).to.equal(1);
+ expect(device.poll_frequency).to.equal(DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS);
+ const params = device.params.reduce((acc, param) => {
+ acc[param.name] = param.value;
+ return acc;
+ }, {});
+ expect(params[DEVICE_PARAM_NAME.CLOUD_READ_STRATEGY]).to.equal('shadow');
+ });
+
+ it('should keep specification group when thing model exposes the same code', () => {
+ const tuyaDevice = {
+ id: 'device-id',
+ name: 'Device',
+ model: 'Model',
+ local_override: true,
+ specifications: {
+ category: 'cz',
+ functions: [{ code: 'switch_1', name: 'Switch From Spec', type: 'Boolean' }],
+ },
+ thing_model: {
+ services: [
+ {
+ properties: [
+ {
+ code: 'switch_1',
+ name: 'Switch From Thing Model',
+ accessMode: 'ro',
+ typeSpec: { type: 'bool' },
+ },
+ {
+ code: 'cur_power',
+ name: 'Current Power',
+ accessMode: 'ro',
+ typeSpec: { min: 0, max: 99999, scale: 1, unit: 'W' },
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ const device = convertDevice.call({ serviceId: 'service-id' }, tuyaDevice);
+
+ const switchFeature = device.features.find((feature) => feature.external_id === 'tuya:device-id:switch_1');
+ const powerFeature = device.features.find((feature) => feature.external_id === 'tuya:device-id:cur_power');
+
+ expect(device.features).to.have.length(2);
+ expect(switchFeature.read_only).to.equal(false);
+ expect(switchFeature.name).to.equal('switch_1');
+ expect(powerFeature.scale).to.equal(1);
+ });
+
+ it('should build features from top-level cloud status when specifications are empty', () => {
+ const tuyaDevice = {
+ id: 'smart-meter-id',
+ name: 'Smart Meter',
+ model: 'Smart Meter',
+ product_id: 'bbcg1hrkrj5rifsd',
+ local_override: true,
+ specifications: {},
+ status: [
+ { code: 'total_power', value: 120 },
+ { code: 'forward_energy_total', value: 35 },
+ ],
+ };
+
+ const device = convertDevice.call({ serviceId: 'service-id' }, tuyaDevice);
+
+ expect(device.device_type).to.equal(DEVICE_TYPES.SMART_METER);
+ expect(device.features.map((feature) => feature.external_id)).to.include('tuya:smart-meter-id:total_power');
+ expect(device.features.map((feature) => feature.external_id)).to.include(
+ 'tuya:smart-meter-id:forward_energy_total',
+ );
+ });
+
+ it('should keep thing model scale metadata when top-level cloud status has same code', () => {
+ const tuyaDevice = {
+ id: 'smart-meter-id',
+ name: 'Smart Meter',
+ model: 'Smart Meter',
+ product_id: 'bbcg1hrkrj5rifsd',
+ local_override: true,
+ specifications: {},
+ status: [
+ { code: 'voltage_a', value: 2323 },
+ { code: 'reverse_energy_total', value: 43222 },
+ ],
+ thing_model: {
+ services: [
+ {
+ properties: [
+ {
+ code: 'voltage_a',
+ accessMode: 'ro',
+ typeSpec: { min: 0, max: 28000, scale: 1, step: 1, unit: 'V' },
+ },
+ {
+ code: 'reverse_energy_total',
+ accessMode: 'ro',
+ typeSpec: { min: 0, max: 99999999, scale: 2, step: 1, unit: 'KWH' },
+ },
+ ],
+ },
+ ],
+ },
+ };
+
+ const device = convertDevice.call({ serviceId: 'service-id' }, tuyaDevice);
+ const voltageFeature = device.features.find((feature) => feature.external_id === 'tuya:smart-meter-id:voltage_a');
+ const reverseTotalFeature = device.features.find(
+ (feature) => feature.external_id === 'tuya:smart-meter-id:reverse_energy_total',
+ );
+
+ expect(voltageFeature).to.not.equal(undefined);
+ expect(voltageFeature.scale).to.equal(1);
+ expect(reverseTotalFeature).to.not.equal(undefined);
+ expect(reverseTotalFeature.scale).to.equal(2);
+ expect(reverseTotalFeature.category).to.equal('energy-sensor');
+ expect(reverseTotalFeature.type).to.equal('export-index');
+ });
+
+ it('should ignore malformed groups and preserve merged values fallbacks', () => {
+ const tuyaDevice = {
+ id: 'smart-meter-id',
+ name: 'Smart Meter',
+ model: 'Smart Meter',
+ product_id: 'bbcg1hrkrj5rifsd',
+ local_override: true,
+ specifications: {
+ status: [
+ { code: 'voltage_a', values: '{"scale":1}' },
+ { code: 'voltage_a', values: '{invalid' },
+ { code: 'reverse_energy_total', values: '{invalid' },
+ { code: 'reverse_energy_total' },
+ { name: 'missing-status-code', values: '{"scale":2}' },
+ ],
+ functions: [{ name: 'missing-function-code', values: '{}' }],
+ },
+ thing_model: {
+ services: [
+ {
+ properties: [
+ { name: 'missing-property-code', accessMode: 'ro', typeSpec: { scale: 8 } },
+ { code: 'voltage_a', accessMode: 'ro', typeSpec: { scale: 2 } },
+ ],
+ },
+ ],
+ },
+ };
+
+ const device = convertDevice.call({ serviceId: 'service-id' }, tuyaDevice);
+ const voltageFeature = device.features.find((feature) => feature.external_id === 'tuya:smart-meter-id:voltage_a');
+ const reverseTotalFeature = device.features.find(
+ (feature) => feature.external_id === 'tuya:smart-meter-id:reverse_energy_total',
+ );
+
+ expect(voltageFeature).to.not.equal(undefined);
+ expect(voltageFeature.scale).to.equal(1);
+ expect(reverseTotalFeature).to.not.equal(undefined);
+ expect(reverseTotalFeature.scale).to.equal(undefined);
+ });
+
+ it('should log inferred type when no supported feature is found', () => {
+ const debugStub = sinon.stub(logger, 'debug');
+ try {
+ const tuyaDevice = {
+ id: 'smart-meter-id',
+ name: 'Smart Meter',
+ model: 'Smart Meter',
+ product_id: 'bbcg1hrkrj5rifsd',
+ local_override: true,
+ specifications: {
+ status: [{ name: 'missing-status-code' }],
+ functions: [{ name: 'missing-function-code' }],
+ },
+ thing_model: {
+ services: [{ properties: [{ name: 'missing-property-code' }] }],
+ },
+ };
+
+ const device = convertDevice.call({ serviceId: 'service-id' }, tuyaDevice);
+ expect(device.device_type).to.equal(DEVICE_TYPES.SMART_METER);
+ expect(device.features).to.have.length(0);
+ expect(debugStub.calledWithMatch(sinon.match(/inferred type=smart-meter/))).to.equal(true);
+ } finally {
+ debugStub.restore();
+ }
+ });
+});
diff --git a/server/test/services/tuya/lib/device/tuya.localMapping.fixtures.test.js b/server/test/services/tuya/lib/device/tuya.localMapping.fixtures.test.js
new file mode 100644
index 0000000000..f79bbb4798
--- /dev/null
+++ b/server/test/services/tuya/lib/device/tuya.localMapping.fixtures.test.js
@@ -0,0 +1,34 @@
+const { expect } = require('chai');
+
+const { getLocalDpsFromCode } = require('../../../../../services/tuya/lib/device/tuya.localMapping');
+const { loadFixtureCases, sortByKey } = require('../../fixtures/fixtureHelper');
+
+describe('Tuya local mapping fixtures', () => {
+ const fixtureCases = loadFixtureCases('localMapping');
+
+ it('should load at least one local mapping fixture case', () => {
+ expect(fixtureCases.length).to.be.greaterThan(0);
+ });
+
+ fixtureCases.forEach((fixtureCase) => {
+ it(`should resolve local dps for ${fixtureCase.manifest.name} from fixture`, () => {
+ const { device, expected } = fixtureCase.manifest.localMapping;
+ const currentDevice = fixtureCase.load(device);
+ const expectedMapping = fixtureCase.load(expected);
+
+ const resolvedMapping = currentDevice.features.reduce((accumulator, feature) => {
+ expect(feature.external_id).to.be.a('string');
+ const code = String(feature.external_id)
+ .split(':')
+ .pop();
+ expect(code)
+ .to.be.a('string')
+ .and.not.equal('');
+ accumulator[code] = getLocalDpsFromCode(code, currentDevice);
+ return accumulator;
+ }, {});
+
+ expect(sortByKey(resolvedMapping)).to.deep.equal(sortByKey(expectedMapping));
+ });
+ });
+});
diff --git a/server/test/services/tuya/lib/device/tuya.localMapping.test.js b/server/test/services/tuya/lib/device/tuya.localMapping.test.js
new file mode 100644
index 0000000000..d23c64b49a
--- /dev/null
+++ b/server/test/services/tuya/lib/device/tuya.localMapping.test.js
@@ -0,0 +1,114 @@
+/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */
+const { expect } = require('chai');
+
+const proxyquire = require('proxyquire')
+ .noCallThru()
+ .noPreserveCache();
+
+const {
+ addFallbackBinaryFeature,
+ getLocalDpsFromCode,
+} = require('../../../../../services/tuya/lib/device/tuya.localMapping');
+
+describe('Tuya local mapping', () => {
+ it('should return device when device is null', () => {
+ const result = addFallbackBinaryFeature(null, { 1: true });
+ expect(result).to.equal(null);
+ });
+
+ it('should return device when external_id is missing', () => {
+ const device = { name: 'Device' };
+ const result = addFallbackBinaryFeature(device, { 1: true });
+ expect(result).to.equal(device);
+ });
+
+ it('should return null when code is missing', () => {
+ const device = { device_type: 'smart-socket' };
+ const dpsKey = getLocalDpsFromCode(undefined, device);
+ expect(dpsKey).to.equal(null);
+ });
+
+ it('should resolve aliases in strict local mapping', () => {
+ const device = { device_type: 'smart-socket' };
+ const dpsKey = getLocalDpsFromCode('power', device);
+ expect(dpsKey).to.equal(1);
+ });
+
+ it('should resolve switch_1 in strict local mapping', () => {
+ const device = { device_type: 'smart-socket' };
+ const dpsKey = getLocalDpsFromCode('switch_1', device);
+ expect(dpsKey).to.equal(1);
+ });
+
+ it('should resolve aliases when direct dps is missing', () => {
+ const { getLocalDpsFromCode: getLocalDpsFromCodeStub } = proxyquire(
+ '../../../../../services/tuya/lib/device/tuya.localMapping',
+ {
+ '../mappings': {
+ getDeviceType: () => 'unknown',
+ getLocalMapping: () => ({
+ strict: true,
+ codeAliases: { foo: ['bar'] },
+ dps: { bar: 7 },
+ }),
+ normalizeCode: (code) => (code ? String(code).toLowerCase() : null),
+ },
+ './tuya.convertFeature': { convertFeature: () => null },
+ },
+ );
+
+ const dpsKey = getLocalDpsFromCodeStub('foo', {});
+ expect(dpsKey).to.equal(7);
+ });
+
+ it('should return null for unknown code in strict local mapping', () => {
+ const device = { device_type: 'smart-socket' };
+ const dpsKey = getLocalDpsFromCode('unknown_code', device);
+ expect(dpsKey).to.equal(null);
+ });
+
+ it('should resolve smart socket telemetry dps in strict local mapping', () => {
+ const device = { device_type: 'smart-socket' };
+
+ expect(getLocalDpsFromCode('add_ele', device)).to.equal(17);
+ expect(getLocalDpsFromCode('cur_current', device)).to.equal(18);
+ expect(getLocalDpsFromCode('cur_power', device)).to.equal(19);
+ expect(getLocalDpsFromCode('cur_voltage', device)).to.equal(20);
+ expect(getLocalDpsFromCode('child_lock', device)).to.equal(41);
+ });
+
+ it('should fallback to switch dps in non-strict mapping without dps', () => {
+ const { getLocalDpsFromCode: getLocalDpsFromCodeStub } = proxyquire(
+ '../../../../../services/tuya/lib/device/tuya.localMapping',
+ {
+ '../mappings': {
+ getDeviceType: () => 'unknown',
+ getLocalMapping: () => ({ strict: false, codeAliases: {}, dps: {} }),
+ normalizeCode: (code) => (code ? String(code).toLowerCase() : null),
+ },
+ './tuya.convertFeature': { convertFeature: () => null },
+ },
+ );
+ const dpsKey = getLocalDpsFromCodeStub('switch', {});
+ expect(dpsKey).to.equal(1);
+ });
+
+ it('should add fallback binary feature when no features and dps 1 exists', () => {
+ const device = {
+ external_id: 'tuya:device',
+ features: [],
+ device_type: 'smart-socket',
+ };
+
+ const result = addFallbackBinaryFeature(device, { 1: true });
+
+ expect(result.features).to.have.length(1);
+ expect(result.features[0].external_id).to.equal('tuya:device:switch_1');
+ });
+
+ it('should resolve total_power in smart meter local mapping', () => {
+ const device = { device_type: 'smart-meter' };
+ const dpsKey = getLocalDpsFromCode('total_power', device);
+ expect(dpsKey).to.equal(115);
+ });
+});
diff --git a/server/test/services/tuya/lib/mappings/index.test.js b/server/test/services/tuya/lib/mappings/index.test.js
new file mode 100644
index 0000000000..4e75b0f105
--- /dev/null
+++ b/server/test/services/tuya/lib/mappings/index.test.js
@@ -0,0 +1,205 @@
+/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */
+const { expect } = require('chai');
+const proxyquire = require('proxyquire').noCallThru();
+
+const mappings = require('../../../../../services/tuya/lib/mappings');
+
+const {
+ DEVICE_TYPES,
+ extractCodesFromSpecifications,
+ extractCodesFromFeatures,
+ extractCodesFromStatusList,
+ getCloudMapping,
+ getLocalMapping,
+ getFeatureMapping,
+ getIgnoredLocalDps,
+ getIgnoredCloudCodes,
+ getDeviceType,
+ normalizeCode,
+} = mappings;
+
+describe('Tuya mappings index', () => {
+ it('should normalize codes', () => {
+ expect(normalizeCode()).to.equal(null);
+ expect(normalizeCode('TeSt')).to.equal('test');
+ expect(normalizeCode(' switch_1 ')).to.equal('switch_1');
+ });
+
+ it('should extract codes from specifications and skip invalid entries', () => {
+ const empty = extractCodesFromSpecifications(null);
+ expect(empty.size).to.equal(0);
+
+ const codes = extractCodesFromSpecifications({
+ functions: [{ code: 'Switch' }, {}],
+ status: [{ code: 'CUR_POWER' }, { code: null }],
+ });
+ expect(Array.from(codes)).to.have.members(['switch', 'cur_power']);
+ });
+
+ it('should extract codes from features', () => {
+ const empty = extractCodesFromFeatures(null);
+ expect(empty.size).to.equal(0);
+
+ const codes = extractCodesFromFeatures([
+ {},
+ { external_id: 'tuya:device:switch' },
+ { external_id: 'tuya:device' },
+ { external_id: 'tuya:device:sub:switch_2' },
+ ]);
+ expect(codes.has('switch')).to.equal(true);
+ expect(codes.has('switch_2')).to.equal(true);
+ expect(codes.has('device')).to.equal(true);
+ expect(codes.size).to.equal(3);
+ });
+
+ it('should extract codes from top-level status list', () => {
+ const empty = extractCodesFromStatusList(null);
+ expect(empty.size).to.equal(0);
+
+ const codes = extractCodesFromStatusList([{ code: 'TOTAL_POWER' }, {}, { code: ' forward_energy_total ' }]);
+ expect(Array.from(codes)).to.have.members(['total_power', 'forward_energy_total']);
+ });
+
+ it('should build cloud and local mappings', () => {
+ const cloud = getCloudMapping(DEVICE_TYPES.SMART_SOCKET);
+ expect(cloud.switch).to.be.an('object');
+ expect(cloud.cur_power).to.be.an('object');
+ expect(cloud.switch_led).to.equal(undefined);
+ const unknownCloud = getCloudMapping(DEVICE_TYPES.UNKNOWN);
+ expect(unknownCloud.switch_led).to.be.an('object');
+ const unsupportedCloud = getCloudMapping('unsupported-device');
+ expect(unsupportedCloud.switch_led).to.be.an('object');
+
+ const local = getLocalMapping(DEVICE_TYPES.SMART_SOCKET);
+ expect(local.strict).to.equal(true);
+ expect(local.dps.switch).to.equal(1);
+ expect(local.ignoredDps).to.include('11');
+ const unknownLocal = getLocalMapping(DEVICE_TYPES.UNKNOWN);
+ expect(unknownLocal.strict).to.equal(false);
+ expect(unknownLocal.ignoredDps).to.not.include('11');
+ const unsupportedLocal = getLocalMapping('unsupported-device');
+ expect(unsupportedLocal.strict).to.equal(false);
+ expect(unsupportedLocal.ignoredDps).to.not.include('11');
+
+ const smartMeterCloud = getCloudMapping(DEVICE_TYPES.SMART_METER);
+ expect(smartMeterCloud.total_power).to.be.an('object');
+ expect(smartMeterCloud.forward_energy_total).to.be.an('object');
+
+ const smartMeterLocal = getLocalMapping(DEVICE_TYPES.SMART_METER);
+ expect(smartMeterLocal.strict).to.equal(true);
+ expect(smartMeterLocal.dps.total_power).to.equal(115);
+ });
+
+ it('should detect device types', () => {
+ expect(getDeviceType(null)).to.equal(DEVICE_TYPES.UNKNOWN);
+
+ const socketByCategoryAndCode = getDeviceType({
+ specifications: { category: 'cz', functions: [{ code: 'switch_1' }], status: [] },
+ model: '',
+ features: [],
+ });
+ expect(socketByCategoryAndCode).to.equal(DEVICE_TYPES.SMART_SOCKET);
+
+ const unknownByCategoryOnly = getDeviceType({
+ specifications: { category: 'cz', functions: [], status: [] },
+ model: '',
+ features: [],
+ });
+ expect(unknownByCategoryOnly).to.equal(DEVICE_TYPES.UNKNOWN);
+
+ const socketByCodesAndName = getDeviceType({
+ specifications: {
+ functions: [{ code: 'switch_1' }],
+ status: [],
+ },
+ model: 'Wifi Plug Mini',
+ });
+ expect(socketByCodesAndName).to.equal(DEVICE_TYPES.SMART_SOCKET);
+
+ const socketByProductId = getDeviceType({
+ specifications: { functions: [], status: [] },
+ product_id: 'cya3zxfd38g4qp8d',
+ });
+ expect(socketByProductId).to.equal(DEVICE_TYPES.SMART_SOCKET);
+
+ const socketByThingModel = getDeviceType({
+ specifications: {},
+ thing_model: {
+ services: [{ properties: [{}, { code: 'switch_1' }] }],
+ },
+ model: 'Wifi Plug Mini',
+ });
+ expect(socketByThingModel).to.equal(DEVICE_TYPES.SMART_SOCKET);
+
+ const socketByProperties = getDeviceType({
+ specifications: {},
+ properties: {
+ properties: [{}, { code: 'switch_1' }],
+ },
+ model: 'Wifi Plug Mini',
+ });
+ expect(socketByProperties).to.equal(DEVICE_TYPES.SMART_SOCKET);
+
+ const smartMeterByProductId = getDeviceType({
+ specifications: {},
+ product_id: 'bbcg1hrkrj5rifsd',
+ });
+ expect(smartMeterByProductId).to.equal(DEVICE_TYPES.SMART_METER);
+
+ const smartMeterByThingModel = getDeviceType({
+ specifications: {},
+ thing_model: {
+ services: [{ properties: [{ code: 'total_power' }, { code: 'forward_energy_total' }] }],
+ },
+ model: 'DIN Smart Meter',
+ });
+ expect(smartMeterByThingModel).to.equal(DEVICE_TYPES.SMART_METER);
+
+ const smartMeterByTopLevelStatus = getDeviceType({
+ specifications: {},
+ status: [{ code: 'total_power' }, { code: 'forward_energy_total' }],
+ model: 'DIN Smart Meter',
+ });
+ expect(smartMeterByTopLevelStatus).to.equal(DEVICE_TYPES.SMART_METER);
+
+ const socketByMergedSources = getDeviceType({
+ specifications: {
+ functions: [{ code: 'timer' }],
+ status: [],
+ },
+ features: [{ external_id: 'tuya:device:switch_1' }],
+ model: 'Wifi Plug Mini',
+ });
+ expect(socketByMergedSources).to.equal(DEVICE_TYPES.SMART_SOCKET);
+
+ const socketByDirectPropertiesArray = getDeviceType({
+ specifications: {},
+ properties: [{}, { code: ' switch_1 ' }],
+ model: 'Wifi Plug Mini',
+ product_name: 'Plug',
+ });
+ expect(socketByDirectPropertiesArray).to.equal(DEVICE_TYPES.SMART_SOCKET);
+ });
+
+ it('should get feature mapping and ignore invalid candidates', () => {
+ expect(getFeatureMapping(null, DEVICE_TYPES.SMART_SOCKET)).to.equal(null);
+ expect(getFeatureMapping('unknown_code', DEVICE_TYPES.SMART_SOCKET)).to.equal(null);
+
+ const { getFeatureMapping: getFeatureMappingStub } = proxyquire('../../../../../services/tuya/lib/mappings', {
+ './cloud/global': { bad_code: { category: 'switch' } },
+ './cloud/smart-socket': {},
+ './local/global': {},
+ './local/smart-socket': {},
+ });
+ expect(getFeatureMappingStub('bad_code')).to.equal(null);
+ });
+
+ it('should expose ignored dps and cloud codes', () => {
+ expect(getIgnoredLocalDps(DEVICE_TYPES.SMART_SOCKET)).to.include('11');
+ const ignoredCloud = getIgnoredCloudCodes(DEVICE_TYPES.SMART_SOCKET);
+ expect(ignoredCloud).to.include('countdown');
+ expect(ignoredCloud).to.include('countdown_1');
+ const ignoredCloudUnknown = getIgnoredCloudCodes(DEVICE_TYPES.UNKNOWN);
+ expect(ignoredCloudUnknown).to.not.include('countdown');
+ });
+});
diff --git a/server/test/services/tuya/lib/tuya.connect.test.js b/server/test/services/tuya/lib/tuya.connect.test.js
index 6a3b646fa3..12b2a16e56 100644
--- a/server/test/services/tuya/lib/tuya.connect.test.js
+++ b/server/test/services/tuya/lib/tuya.connect.test.js
@@ -11,7 +11,7 @@ const connect = proxyquire('../../../../services/tuya/lib/tuya.connect', {
const TuyaHandler = proxyquire('../../../../services/tuya/lib/index', {
'./tuya.connect.js': connect,
});
-const { STATUS } = require('../../../../services/tuya/lib/utils/tuya.constants');
+const { STATUS, GLADYS_VARIABLES } = require('../../../../services/tuya/lib/utils/tuya.constants');
const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants');
const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors');
@@ -19,6 +19,9 @@ const gladys = {
event: {
emit: fake.returns(null),
},
+ variable: {
+ setValue: fake.resolves(null),
+ },
};
const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
@@ -49,6 +52,7 @@ describe('TuyaHandler.connect', () => {
assert.notCalled(gladys.event.emit);
assert.notCalled(client.init);
+ assert.notCalled(gladys.variable.setValue);
});
it('no access key stored, should fail', async () => {
@@ -66,6 +70,7 @@ describe('TuyaHandler.connect', () => {
assert.notCalled(gladys.event.emit);
assert.notCalled(client.init);
+ assert.notCalled(gladys.variable.setValue);
});
it('no secret key stored, should fail', async () => {
@@ -83,6 +88,7 @@ describe('TuyaHandler.connect', () => {
assert.notCalled(gladys.event.emit);
assert.notCalled(client.init);
+ assert.notCalled(gladys.variable.setValue);
});
it('well connected', async () => {
@@ -90,11 +96,20 @@ describe('TuyaHandler.connect', () => {
baseUrl: 'apiUrl',
accessKey: 'accessKey',
secretKey: 'secretKey',
+ appAccountId: 'appAccountId',
});
expect(tuyaHandler.status).to.eq(STATUS.CONNECTED);
assert.calledOnce(client.init);
+ assert.calledTwice(gladys.variable.setValue);
+ assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.MANUAL_DISCONNECT, 'false', serviceId);
+ assert.calledWith(
+ gladys.variable.setValue,
+ GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH,
+ sinon.match.string,
+ serviceId,
+ );
assert.callCount(gladys.event.emit, 2);
assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, {
@@ -103,7 +118,7 @@ describe('TuyaHandler.connect', () => {
});
assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, {
type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS,
- payload: { status: STATUS.CONNECTED },
+ payload: { status: STATUS.CONNECTED, error: null },
});
});
@@ -114,20 +129,159 @@ describe('TuyaHandler.connect', () => {
baseUrl: 'apiUrl',
accessKey: 'accessKey',
secretKey: 'secretKey',
+ appAccountId: 'appAccountId',
});
expect(tuyaHandler.status).to.eq(STATUS.ERROR);
assert.calledOnce(client.init);
+ assert.notCalled(gladys.variable.setValue);
- assert.callCount(gladys.event.emit, 2);
+ assert.callCount(gladys.event.emit, 3);
assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, {
type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS,
payload: { status: STATUS.CONNECTING },
});
+ assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, {
+ type: WEBSOCKET_MESSAGE_TYPES.TUYA.ERROR,
+ payload: { message: 'Error' },
+ });
assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, {
type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS,
- payload: { status: STATUS.ERROR },
+ payload: { status: STATUS.ERROR, error: 'Error' },
+ });
+ });
+
+ it('should map invalid client id error', async () => {
+ client.init.rejects(new Error('GET_TOKEN_FAILED 2009, clientId is invalid'));
+ tuyaHandler.autoReconnectAllowed = true;
+
+ await tuyaHandler.connect({
+ baseUrl: 'apiUrl',
+ accessKey: 'accessKey',
+ secretKey: 'secretKey',
+ appAccountId: 'appAccountId',
+ });
+
+ expect(tuyaHandler.status).to.eq(STATUS.ERROR);
+ expect(tuyaHandler.lastError).to.eq('integration.tuya.setup.errorInvalidClientId');
+ expect(tuyaHandler.autoReconnectAllowed).to.equal(false);
+ });
+
+ it('should map invalid client secret error', async () => {
+ client.init.rejects(new Error('GET_TOKEN_FAILED 1004, sign invalid'));
+ tuyaHandler.autoReconnectAllowed = true;
+
+ await tuyaHandler.connect({
+ baseUrl: 'apiUrl',
+ accessKey: 'accessKey',
+ secretKey: 'secretKey',
+ appAccountId: 'appAccountId',
});
+
+ expect(tuyaHandler.status).to.eq(STATUS.ERROR);
+ expect(tuyaHandler.lastError).to.eq('integration.tuya.setup.errorInvalidClientSecret');
+ expect(tuyaHandler.autoReconnectAllowed).to.equal(false);
+ });
+
+ it('should map invalid endpoint error', async () => {
+ client.init.rejects(new Error('No permission. The data center is suspended.'));
+ tuyaHandler.autoReconnectAllowed = true;
+
+ await tuyaHandler.connect({
+ baseUrl: 'apiUrl',
+ accessKey: 'accessKey',
+ secretKey: 'secretKey',
+ appAccountId: 'appAccountId',
+ });
+
+ expect(tuyaHandler.status).to.eq(STATUS.ERROR);
+ expect(tuyaHandler.lastError).to.eq('integration.tuya.setup.errorInvalidEndpoint');
+ expect(tuyaHandler.autoReconnectAllowed).to.equal(false);
+ });
+
+ it('should map missing app account uid error', async () => {
+ client.init.resolves();
+ tuyaHandler.autoReconnectAllowed = true;
+
+ await tuyaHandler.connect({
+ baseUrl: 'apiUrl',
+ accessKey: 'accessKey',
+ secretKey: 'secretKey',
+ appAccountId: '',
+ });
+
+ expect(tuyaHandler.status).to.eq(STATUS.ERROR);
+ expect(tuyaHandler.lastError).to.eq('integration.tuya.setup.errorInvalidAppAccountUid');
+ expect(tuyaHandler.autoReconnectAllowed).to.equal(false);
+ assert.notCalled(tuyaHandler.connector.request);
+ });
+
+ it('should map invalid app account uid from api response', async () => {
+ const clientStub = {
+ init: sinon.stub().resolves(),
+ };
+ const requestStub = sinon.stub().resolves({
+ success: false,
+ msg: 'permission deny',
+ code: 1106,
+ });
+ const TuyaContextStub = function TuyaContextStub() {
+ this.client = clientStub;
+ this.request = requestStub;
+ };
+
+ const connectWithStub = proxyquire('../../../../services/tuya/lib/tuya.connect', {
+ '@tuya/tuya-connector-nodejs': { TuyaContext: TuyaContextStub },
+ });
+ const TuyaHandlerWithStub = proxyquire('../../../../services/tuya/lib/index', {
+ './tuya.connect.js': connectWithStub,
+ });
+ const handler = new TuyaHandlerWithStub(gladys, serviceId);
+ handler.autoReconnectAllowed = true;
+
+ await handler.connect({
+ baseUrl: 'apiUrl',
+ accessKey: 'accessKey',
+ secretKey: 'secretKey',
+ appAccountId: 'appAccountId',
+ });
+
+ expect(handler.status).to.eq(STATUS.ERROR);
+ expect(handler.lastError).to.eq('integration.tuya.setup.errorInvalidAppAccountUid');
+ expect(handler.autoReconnectAllowed).to.equal(false);
+ assert.calledOnce(requestStub);
+ });
+
+ it('should map invalid app account uid from empty api response', async () => {
+ const clientStub = {
+ init: sinon.stub().resolves(),
+ };
+ const requestStub = sinon.stub().resolves(null);
+ const TuyaContextStub = function TuyaContextStub() {
+ this.client = clientStub;
+ this.request = requestStub;
+ };
+
+ const connectWithStub = proxyquire('../../../../services/tuya/lib/tuya.connect', {
+ '@tuya/tuya-connector-nodejs': { TuyaContext: TuyaContextStub },
+ });
+ const TuyaHandlerWithStub = proxyquire('../../../../services/tuya/lib/index', {
+ './tuya.connect.js': connectWithStub,
+ });
+ const handler = new TuyaHandlerWithStub(gladys, serviceId);
+ handler.autoReconnectAllowed = true;
+
+ await handler.connect({
+ baseUrl: 'apiUrl',
+ accessKey: 'accessKey',
+ secretKey: 'secretKey',
+ appAccountId: 'appAccountId',
+ });
+
+ expect(handler.status).to.eq(STATUS.ERROR);
+ expect(handler.lastError).to.eq('integration.tuya.setup.errorInvalidAppAccountUid');
+ expect(handler.autoReconnectAllowed).to.equal(false);
+ assert.calledOnce(requestStub);
});
});
diff --git a/server/test/services/tuya/lib/tuya.disconnect.test.js b/server/test/services/tuya/lib/tuya.disconnect.test.js
index b6731986ff..0d8ce679bb 100644
--- a/server/test/services/tuya/lib/tuya.disconnect.test.js
+++ b/server/test/services/tuya/lib/tuya.disconnect.test.js
@@ -1,9 +1,15 @@
const { expect } = require('chai');
+const sinon = require('sinon');
const TuyaHandler = require('../../../../services/tuya/lib/index');
const { STATUS } = require('../../../../services/tuya/lib/utils/tuya.constants');
+const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants');
-const gladys = {};
+const gladys = {
+ event: {
+ emit: sinon.fake.returns(null),
+ },
+};
const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
describe('TuyaHandler.disconnect', () => {
@@ -11,6 +17,8 @@ describe('TuyaHandler.disconnect', () => {
beforeEach(() => {
tuyaHandler.status = 'UNKNOWN';
+ tuyaHandler.lastError = 'previous-error';
+ gladys.event.emit.resetHistory();
});
it('should reset attributes', () => {
@@ -18,5 +26,19 @@ describe('TuyaHandler.disconnect', () => {
expect(tuyaHandler.status).to.eq(STATUS.NOT_INITIALIZED);
expect(tuyaHandler.connector).to.eq(null);
+ expect(tuyaHandler.lastError).to.eq(null);
+ sinon.assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, {
+ type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS,
+ payload: { status: STATUS.NOT_INITIALIZED, manual_disconnect: false },
+ });
+ });
+
+ it('should send manual disconnect status', () => {
+ tuyaHandler.disconnect({ manual: true });
+
+ sinon.assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, {
+ type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS,
+ payload: { status: STATUS.NOT_INITIALIZED, manual_disconnect: true },
+ });
});
});
diff --git a/server/test/services/tuya/lib/tuya.discoverDevices.test.js b/server/test/services/tuya/lib/tuya.discoverDevices.test.js
index 7c537b429d..4dd6232087 100644
--- a/server/test/services/tuya/lib/tuya.discoverDevices.test.js
+++ b/server/test/services/tuya/lib/tuya.discoverDevices.test.js
@@ -4,7 +4,7 @@ const sinon = require('sinon');
const { assert, fake } = sinon;
const TuyaHandler = require('../../../../services/tuya/lib/index');
-const { STATUS } = require('../../../../services/tuya/lib/utils/tuya.constants');
+const { API, STATUS } = require('../../../../services/tuya/lib/utils/tuya.constants');
const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants');
const { ServiceNotConfiguredError } = require('../../../../utils/coreErrors');
@@ -18,6 +18,9 @@ const gladys = {
variable: {
getValue: fake.resolves('APP_ACCOUNT_UID'),
},
+ device: {
+ get: fake.resolves([]),
+ },
};
const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
@@ -26,13 +29,28 @@ describe('TuyaHandler.discoverDevices', () => {
beforeEach(() => {
sinon.reset();
+ gladys.event.emit = fake.resolves(null);
+ gladys.stateManager.get = fake.returns(null);
+ gladys.variable.getValue = fake.resolves('APP_ACCOUNT_UID');
+ gladys.device.get = fake.resolves([]);
tuyaHandler.status = STATUS.CONNECTED;
tuyaHandler.connector = {
request: sinon
.stub()
- .onFirstCall()
- .resolves({ result: { list: [{ name: 'name', id: 'uuid', product_name: 'model' }] } })
- .onSecondCall()
+ .onCall(0)
+ .resolves({
+ result: [
+ {
+ name: 'name',
+ id: 'uuid',
+ product_name: 'model',
+ local_key: 'localKey',
+ ip: '1.1.1.1',
+ online: true,
+ },
+ ],
+ })
+ .onCall(1)
.resolves({
result: {
details: 'details',
@@ -50,6 +68,26 @@ describe('TuyaHandler.discoverDevices', () => {
type: 'Integer',
},
],
+ category: 'cz',
+ },
+ })
+ .onCall(2)
+ .resolves({
+ result: {
+ local_key: 'localKey',
+ ip: '1.1.1.1',
+ },
+ })
+ .onCall(3)
+ .resolves({
+ result: {
+ properties: [{ code: 'switch_1', value: true }],
+ },
+ })
+ .onCall(4)
+ .resolves({
+ result: {
+ model: '{"services":[]}',
},
}),
};
@@ -99,42 +137,212 @@ describe('TuyaHandler.discoverDevices', () => {
it('should load devices', async () => {
const devices = await tuyaHandler.discoverDevices();
- expect(devices).to.deep.eq([
- {
- external_id: 'tuya:uuid',
- features: [
+ expect(devices).to.have.lengthOf(1);
+
+ const [{ tuya_mapping: tuyaMapping, ...device }] = devices;
+
+ expect(tuyaMapping).to.be.an('object');
+ expect(tuyaMapping.ignored_cloud_codes).to.be.an('array');
+ expect(tuyaMapping.ignored_local_dps).to.be.an('array');
+
+ expect(device).to.deep.eq({
+ external_id: 'tuya:uuid',
+ features: [
+ {
+ category: 'switch',
+ external_id: 'tuya:uuid:cur_power',
+ has_feedback: false,
+ max: 1,
+ min: 0,
+ name: 'cur_power',
+ read_only: true,
+ selector: 'tuya:uuid:cur_power',
+ type: 'power',
+ unit: 'watt',
+ },
+ {
+ category: 'switch',
+ external_id: 'tuya:uuid:switch_1',
+ has_feedback: false,
+ max: 1,
+ min: 0,
+ name: 'switch_1',
+ read_only: false,
+ selector: 'tuya:uuid:switch_1',
+ type: 'binary',
+ },
+ ],
+ device_type: 'smart-socket',
+ model: 'model',
+ name: 'name',
+ poll_frequency: 30000,
+ params: [
+ {
+ name: 'DEVICE_ID',
+ value: 'uuid',
+ },
+ {
+ name: 'LOCAL_KEY',
+ value: 'localKey',
+ },
+ {
+ name: 'CLOUD_IP',
+ value: '1.1.1.1',
+ },
+ {
+ name: 'LOCAL_OVERRIDE',
+ value: false,
+ },
+ {
+ name: 'CLOUD_READ_STRATEGY',
+ value: 'legacy',
+ },
+ ],
+ properties: {
+ properties: [{ code: 'switch_1', value: true }],
+ },
+ product_id: undefined,
+ product_key: undefined,
+ specifications: {
+ details: 'details',
+ category: 'cz',
+ functions: [
{
- category: 'switch',
- external_id: 'tuya:uuid:cur_power',
- has_feedback: false,
- max: 1,
- min: 0,
- name: 'cur_power',
- read_only: true,
- selector: 'tuya:uuid:cur_power',
- type: 'power',
- unit: 'watt',
+ code: 'switch_1',
+ name: 'name',
+ type: 'Boolean',
},
+ ],
+ status: [
{
- category: 'switch',
- external_id: 'tuya:uuid:switch_1',
- has_feedback: false,
- max: 1,
- min: 0,
- name: 'name',
- read_only: false,
- selector: 'tuya:uuid:switch_1',
- type: 'binary',
+ code: 'cur_power',
+ name: 'cur_power',
+ type: 'Integer',
},
],
- model: 'model',
- name: 'name',
- poll_frequency: 30000,
- selector: 'tuya:uuid',
- service_id: 'ffa13430-df93-488a-9733-5c540e9558e0',
- should_poll: true,
},
- ]);
+ selector: 'tuya:uuid',
+ service_id: 'ffa13430-df93-488a-9733-5c540e9558e0',
+ should_poll: true,
+ thing_model: {
+ services: [],
+ },
+ tuya_report: {
+ schema_version: 2,
+ cloud: {
+ assembled: {
+ specifications: {
+ details: 'details',
+ category: 'cz',
+ functions: [
+ {
+ code: 'switch_1',
+ name: 'name',
+ type: 'Boolean',
+ },
+ ],
+ status: [
+ {
+ code: 'cur_power',
+ name: 'cur_power',
+ type: 'Integer',
+ },
+ ],
+ },
+ properties: {
+ properties: [{ code: 'switch_1', value: true }],
+ },
+ thing_model: {
+ services: [],
+ },
+ },
+ raw: {
+ device_list_entry: {
+ request: {
+ method: 'GET',
+ path: '/v1.0/users/{sourceId}/devices',
+ },
+ response_item: {
+ name: 'name',
+ id: 'uuid',
+ product_name: 'model',
+ local_key: 'localKey',
+ ip: '1.1.1.1',
+ online: true,
+ },
+ },
+ device_specification: {
+ request: {
+ method: 'GET',
+ path: `${API.VERSION_1_2}/devices/uuid/specification`,
+ },
+ response: {
+ result: {
+ details: 'details',
+ functions: [
+ {
+ name: 'name',
+ code: 'switch_1',
+ type: 'Boolean',
+ },
+ ],
+ status: [
+ {
+ name: 'cur_power',
+ code: 'cur_power',
+ type: 'Integer',
+ },
+ ],
+ category: 'cz',
+ },
+ },
+ error: null,
+ },
+ device_details: {
+ request: {
+ method: 'GET',
+ path: `${API.VERSION_1_0}/devices/uuid`,
+ },
+ response: {
+ result: {
+ local_key: 'localKey',
+ ip: '1.1.1.1',
+ },
+ },
+ error: null,
+ },
+ thing_shadow_properties: {
+ request: {
+ method: 'GET',
+ path: `${API.VERSION_2_0}/thing/uuid/shadow/properties`,
+ },
+ response: {
+ result: {
+ properties: [{ code: 'switch_1', value: true }],
+ },
+ },
+ error: null,
+ },
+ thing_model: {
+ request: {
+ method: 'GET',
+ path: `${API.VERSION_2_0}/thing/uuid/model`,
+ },
+ response: {
+ result: {
+ model: '{"services":[]}',
+ },
+ },
+ error: null,
+ },
+ },
+ },
+ local: {
+ scan: null,
+ },
+ },
+ online: true,
+ });
assert.callCount(gladys.event.emit, 2);
assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, {
@@ -146,6 +354,47 @@ describe('TuyaHandler.discoverDevices', () => {
payload: { status: STATUS.CONNECTED },
});
- assert.calledTwice(tuyaHandler.connector.request);
+ assert.callCount(tuyaHandler.connector.request, 5);
+ });
+
+ it('should keep local params from existing devices', async () => {
+ gladys.stateManager.get = fake.returns({
+ external_id: 'tuya:uuid',
+ params: [
+ { name: 'IP_ADDRESS', value: '2.2.2.2' },
+ { name: 'PROTOCOL_VERSION', value: '3.3' },
+ { name: 'LOCAL_OVERRIDE', value: true },
+ ],
+ features: [{ external_id: 'tuya:uuid:cur_power' }, { external_id: 'tuya:uuid:switch_1' }],
+ });
+
+ const devices = await tuyaHandler.discoverDevices();
+ const { params } = devices[0];
+ const getParam = (name) => params.find((param) => param.name === name);
+
+ expect(getParam('IP_ADDRESS').value).to.equal('2.2.2.2');
+ expect(getParam('PROTOCOL_VERSION').value).to.equal('3.3');
+ expect(getParam('LOCAL_OVERRIDE').value).to.equal(true);
+ });
+
+ it('should append existing devices not returned by discovery', async () => {
+ gladys.device.get = fake.resolves([
+ { external_id: 'tuya:existing', name: 'Existing device', params: [] },
+ { name: 'missing external id' },
+ ]);
+
+ const devices = await tuyaHandler.discoverDevices();
+ const existing = devices.find((device) => device.external_id === 'tuya:existing');
+
+ expect(existing).to.not.equal(undefined);
+ expect(existing.updatable).to.equal(false);
+ });
+
+ it('should continue when loading existing devices fails', async () => {
+ gladys.device.get = fake.rejects(new Error('failure'));
+
+ const devices = await tuyaHandler.discoverDevices();
+ expect(devices).to.be.an('array');
+ expect(devices.length).to.be.greaterThan(0);
});
});
diff --git a/server/test/services/tuya/lib/tuya.getConfiguration.test.js b/server/test/services/tuya/lib/tuya.getConfiguration.test.js
index fad9a7c485..cea8773a18 100644
--- a/server/test/services/tuya/lib/tuya.getConfiguration.test.js
+++ b/server/test/services/tuya/lib/tuya.getConfiguration.test.js
@@ -31,7 +31,11 @@ describe('TuyaHandler.getConfiguration', () => {
.withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId)
.returns('accessKey')
.withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId)
- .returns('secretKey');
+ .returns('secretKey')
+ .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId)
+ .returns('appAccountId')
+ .withArgs(GLADYS_VARIABLES.APP_USERNAME, serviceId)
+ .returns('user@example.com');
const config = await tuyaHandler.getConfiguration();
@@ -39,11 +43,16 @@ describe('TuyaHandler.getConfiguration', () => {
baseUrl: 'https://openapi-ueaz.tuyaus.com',
accessKey: 'accessKey',
secretKey: 'secretKey',
+ appUsername: 'user@example.com',
+ endpoint: 'easternAmerica',
+ appAccountId: 'appAccountId',
});
- assert.callCount(gladys.variable.getValue, 3);
+ assert.callCount(gladys.variable.getValue, 5);
assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ENDPOINT, serviceId);
assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ACCESS_KEY, serviceId);
assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.SECRET_KEY, serviceId);
+ assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId);
+ assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.APP_USERNAME, serviceId);
});
});
diff --git a/server/test/services/tuya/lib/tuya.getStatus.test.js b/server/test/services/tuya/lib/tuya.getStatus.test.js
new file mode 100644
index 0000000000..2d5c2961c9
--- /dev/null
+++ b/server/test/services/tuya/lib/tuya.getStatus.test.js
@@ -0,0 +1,86 @@
+const { expect } = require('chai');
+const sinon = require('sinon');
+
+const { assert } = sinon;
+
+const TuyaHandler = require('../../../../services/tuya/lib/index');
+const { GLADYS_VARIABLES, STATUS } = require('../../../../services/tuya/lib/utils/tuya.constants');
+
+const gladys = {
+ variable: {
+ getValue: sinon.stub(),
+ },
+};
+const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
+
+describe('TuyaHandler.getStatus', () => {
+ const tuyaHandler = new TuyaHandler(gladys, serviceId);
+
+ beforeEach(() => {
+ sinon.reset();
+ });
+
+ afterEach(() => {
+ sinon.reset();
+ });
+
+ it('should return configured=false when credentials are missing', async () => {
+ gladys.variable.getValue
+ .withArgs(GLADYS_VARIABLES.ENDPOINT, serviceId)
+ .returns(null)
+ .withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId)
+ .returns('accessKey')
+ .withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId)
+ .returns('secretKey')
+ .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId)
+ .returns('appAccountId')
+ .withArgs(GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId)
+ .returns(null);
+
+ tuyaHandler.status = STATUS.NOT_INITIALIZED;
+ tuyaHandler.lastError = null;
+
+ const status = await tuyaHandler.getStatus();
+
+ expect(status).to.deep.eq({
+ status: STATUS.NOT_INITIALIZED,
+ connected: false,
+ configured: false,
+ error: null,
+ manual_disconnect: false,
+ });
+
+ assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ENDPOINT, serviceId);
+ assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ACCESS_KEY, serviceId);
+ assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.SECRET_KEY, serviceId);
+ assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId);
+ assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId);
+ });
+
+ it('should return manual_disconnect=true when stored as string', async () => {
+ gladys.variable.getValue
+ .withArgs(GLADYS_VARIABLES.ENDPOINT, serviceId)
+ .returns('endpoint')
+ .withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId)
+ .returns('accessKey')
+ .withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId)
+ .returns('secretKey')
+ .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId)
+ .returns('appAccountId')
+ .withArgs(GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId)
+ .returns('1');
+
+ tuyaHandler.status = STATUS.CONNECTED;
+ tuyaHandler.lastError = 'nope';
+
+ const status = await tuyaHandler.getStatus();
+
+ expect(status).to.deep.eq({
+ status: STATUS.CONNECTED,
+ connected: true,
+ configured: true,
+ error: 'nope',
+ manual_disconnect: true,
+ });
+ });
+});
diff --git a/server/test/services/tuya/lib/tuya.init.test.js b/server/test/services/tuya/lib/tuya.init.test.js
index dbd7cdb01d..4c3ab52a78 100644
--- a/server/test/services/tuya/lib/tuya.init.test.js
+++ b/server/test/services/tuya/lib/tuya.init.test.js
@@ -17,6 +17,7 @@ const { EVENTS, WEBSOCKET_MESSAGE_TYPES } = require('../../../../utils/constants
const gladys = {
variable: {
getValue: sinon.stub(),
+ setValue: sinon.stub().resolves(null),
},
event: {
emit: fake.returns(null),
@@ -30,6 +31,8 @@ describe('TuyaHandler.init', () => {
beforeEach(() => {
sinon.reset();
tuyaHandler.status = 'UNKNOWN';
+ tuyaHandler.autoReconnectAllowed = false;
+ tuyaHandler.lastError = 'previous-error';
});
afterEach(() => {
@@ -43,16 +46,28 @@ describe('TuyaHandler.init', () => {
.withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId)
.returns('accessKey')
.withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId)
- .returns('secretKey');
+ .returns('secretKey')
+ .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId)
+ .returns('appAccountId')
+ .withArgs(GLADYS_VARIABLES.APP_USERNAME, serviceId)
+ .returns('appUsername')
+ .withArgs(GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, serviceId)
+ .returns(null)
+ .withArgs(GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId)
+ .returns(null);
await tuyaHandler.init();
expect(tuyaHandler.status).to.eq(STATUS.CONNECTED);
- assert.callCount(gladys.variable.getValue, 3);
+ assert.callCount(gladys.variable.getValue, 7);
assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ENDPOINT, serviceId);
assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.ACCESS_KEY, serviceId);
assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.SECRET_KEY, serviceId);
+ assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId);
+ assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.APP_USERNAME, serviceId);
+ assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, serviceId);
+ assert.calledWith(gladys.variable.getValue, GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId);
assert.calledOnce(client.init);
@@ -63,7 +78,78 @@ describe('TuyaHandler.init', () => {
});
assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, {
type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS,
- payload: { status: STATUS.CONNECTED },
+ payload: { status: STATUS.CONNECTED, error: null },
});
});
+
+ it('should not connect when manual disconnect is enabled', async () => {
+ gladys.variable.getValue
+ .withArgs(GLADYS_VARIABLES.ENDPOINT, serviceId)
+ .returns('apiUrl')
+ .withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId)
+ .returns('accessKey')
+ .withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId)
+ .returns('secretKey')
+ .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId)
+ .returns('appAccountId')
+ .withArgs(GLADYS_VARIABLES.APP_USERNAME, serviceId)
+ .returns('appUsername')
+ .withArgs(GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, serviceId)
+ .returns(null)
+ .withArgs(GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId)
+ .returns('1');
+
+ await tuyaHandler.init();
+
+ expect(tuyaHandler.status).to.eq(STATUS.NOT_INITIALIZED);
+
+ assert.notCalled(client.init);
+ assert.calledWith(gladys.event.emit, EVENTS.WEBSOCKET.SEND_ALL, {
+ type: WEBSOCKET_MESSAGE_TYPES.TUYA.STATUS,
+ payload: { status: STATUS.NOT_INITIALIZED, manual_disconnect: true },
+ });
+ });
+
+ it('should allow auto-reconnect when last connected hash matches current config', async () => {
+ gladys.variable.getValue
+ .withArgs(GLADYS_VARIABLES.ENDPOINT, serviceId)
+ .returns('apiUrl')
+ .withArgs(GLADYS_VARIABLES.ACCESS_KEY, serviceId)
+ .returns('accessKey')
+ .withArgs(GLADYS_VARIABLES.SECRET_KEY, serviceId)
+ .returns('secretKey')
+ .withArgs(GLADYS_VARIABLES.APP_ACCOUNT_UID, serviceId)
+ .returns('appAccountId')
+ .withArgs(GLADYS_VARIABLES.APP_USERNAME, serviceId)
+ .returns('appUsername')
+ .withArgs(GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, serviceId)
+ .returns('9fa6af9a941ec217207b27f44ce07efcb447bb2173f4ce8f238a2aeb7ad9f8ea')
+ .withArgs(GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId)
+ .returns(false);
+
+ await tuyaHandler.init();
+
+ expect(tuyaHandler.autoReconnectAllowed).to.equal(true);
+ assert.calledOnce(client.init);
+ });
+
+ it('should connect with null configuration when no config is stored', async () => {
+ const connectStub = sinon.stub(tuyaHandler, 'connect').resolves();
+ const getConfigurationStub = sinon.stub(tuyaHandler, 'getConfiguration').resolves(null);
+ gladys.variable.getValue
+ .withArgs(GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, serviceId)
+ .returns('some-hash')
+ .withArgs(GLADYS_VARIABLES.MANUAL_DISCONNECT, serviceId)
+ .returns(0);
+
+ await tuyaHandler.init();
+
+ expect(tuyaHandler.autoReconnectAllowed).to.equal(false);
+ assert.calledOnce(getConfigurationStub);
+ assert.calledOnce(connectStub);
+ assert.calledWith(connectStub, null);
+
+ connectStub.restore();
+ getConfigurationStub.restore();
+ });
});
diff --git a/server/test/services/tuya/lib/tuya.loadDeviceDetails.test.js b/server/test/services/tuya/lib/tuya.loadDeviceDetails.test.js
index e3b024b6be..2fdb448f30 100644
--- a/server/test/services/tuya/lib/tuya.loadDeviceDetails.test.js
+++ b/server/test/services/tuya/lib/tuya.loadDeviceDetails.test.js
@@ -1,37 +1,437 @@
const { expect } = require('chai');
const sinon = require('sinon');
-const { assert, fake } = sinon;
+const { assert } = sinon;
const TuyaHandler = require('../../../../services/tuya/lib/index');
const { API } = require('../../../../services/tuya/lib/utils/tuya.constants');
+const logger = require('../../../../utils/logger');
const gladys = {};
const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
+const buildExpectedReport = ({
+ deviceId,
+ listEntry,
+ specificationResponse,
+ detailsResponse,
+ propertiesResponse,
+ modelResponse,
+ specificationError = null,
+ detailsError = null,
+ propertiesError = null,
+ modelError = null,
+ specifications = null,
+ properties = null,
+ thingModel = null,
+}) => ({
+ schema_version: 2,
+ cloud: {
+ assembled: {
+ specifications,
+ properties,
+ thing_model: thingModel,
+ },
+ raw: {
+ device_list_entry: {
+ request: {
+ method: 'GET',
+ path: `${API.PUBLIC_VERSION_1_0}/users/{sourceId}/devices`,
+ },
+ response_item: listEntry,
+ },
+ device_specification: {
+ request: {
+ method: 'GET',
+ path: `${API.VERSION_1_2}/devices/${deviceId}/specification`,
+ },
+ response: specificationResponse,
+ error: specificationError,
+ },
+ device_details: {
+ request: {
+ method: 'GET',
+ path: `${API.VERSION_1_0}/devices/${deviceId}`,
+ },
+ response: detailsResponse,
+ error: detailsError,
+ },
+ thing_shadow_properties: {
+ request: {
+ method: 'GET',
+ path: `${API.VERSION_2_0}/thing/${deviceId}/shadow/properties`,
+ },
+ response: propertiesResponse,
+ error: propertiesError,
+ },
+ thing_model: {
+ request: {
+ method: 'GET',
+ path: `${API.VERSION_2_0}/thing/${deviceId}/model`,
+ },
+ response: modelResponse,
+ error: modelError,
+ },
+ },
+ },
+ local: {
+ scan: null,
+ },
+});
+
describe('TuyaHandler.loadDeviceDetails', () => {
const tuyaHandler = new TuyaHandler(gladys, serviceId);
beforeEach(() => {
sinon.reset();
tuyaHandler.connector = {
- request: fake.resolves({ result: { details: 'details' } }),
+ request: sinon
+ .stub()
+ .onCall(0)
+ .resolves({ result: { details: 'specification' } })
+ .onCall(1)
+ .resolves({ result: { local_key: 'localKey' } })
+ .onCall(2)
+ .resolves({ result: { dps: { 1: true } } })
+ .onCall(3)
+ .resolves({ result: { model: '{"services":[]}' } }),
};
});
afterEach(() => {
sinon.reset();
+ if (logger.warn.restore) {
+ logger.warn.restore();
+ }
});
it('should load device details', async () => {
const devices = await tuyaHandler.loadDeviceDetails({ id: 1 });
- expect(devices).to.deep.eq({ id: 1, specifications: { details: 'details' } });
+ expect(devices).to.deep.eq({
+ id: 1,
+ local_key: 'localKey',
+ specifications: { details: 'specification' },
+ properties: { dps: { 1: true } },
+ thing_model: { services: [] },
+ tuya_report: buildExpectedReport({
+ deviceId: 1,
+ listEntry: { id: 1 },
+ specificationResponse: { result: { details: 'specification' } },
+ detailsResponse: { result: { local_key: 'localKey' } },
+ propertiesResponse: { result: { dps: { 1: true } } },
+ modelResponse: { result: { model: '{"services":[]}' } },
+ specifications: { details: 'specification' },
+ properties: { dps: { 1: true } },
+ thingModel: { services: [] },
+ }),
+ });
- assert.callCount(tuyaHandler.connector.request, 1);
+ assert.callCount(tuyaHandler.connector.request, 4);
assert.calledWith(tuyaHandler.connector.request, {
method: 'GET',
path: `${API.VERSION_1_2}/devices/1/specification`,
});
+ assert.calledWith(tuyaHandler.connector.request, {
+ method: 'GET',
+ path: `${API.VERSION_1_0}/devices/1`,
+ });
+ assert.calledWith(tuyaHandler.connector.request, {
+ method: 'GET',
+ path: `${API.VERSION_2_0}/thing/1/shadow/properties`,
+ });
+ assert.calledWith(tuyaHandler.connector.request, {
+ method: 'GET',
+ path: `${API.VERSION_2_0}/thing/1/model`,
+ });
+ });
+
+ it('should preserve category in specifications when only available in details', async () => {
+ tuyaHandler.connector.request = sinon
+ .stub()
+ .onCall(0)
+ .resolves({ result: { functions: [], status: [] } })
+ .onCall(1)
+ .resolves({ result: { local_key: 'localKey', category: 'cz' } })
+ .onCall(2)
+ .resolves({ result: { dps: { 1: true } } })
+ .onCall(3)
+ .resolves({ result: { model: '{"services":[]}' } });
+
+ const device = await tuyaHandler.loadDeviceDetails({ id: 1 });
+
+ expect(device.specifications.category).to.equal('cz');
+ });
+
+ it('should warn when specifications loading fails', async () => {
+ const warnStub = sinon.stub(logger, 'warn');
+ tuyaHandler.connector.request = sinon
+ .stub()
+ .onCall(0)
+ .rejects(new Error('spec failure'))
+ .onCall(1)
+ .resolves({ result: { local_key: 'localKey' } })
+ .onCall(2)
+ .resolves({ result: { dps: { 1: true } } })
+ .onCall(3)
+ .resolves({ result: { model: '{"services":[]}' } });
+
+ const device = await tuyaHandler.loadDeviceDetails({ id: 1 });
+
+ expect(device).to.deep.eq({
+ id: 1,
+ local_key: 'localKey',
+ specifications: {},
+ properties: { dps: { 1: true } },
+ thing_model: { services: [] },
+ tuya_report: buildExpectedReport({
+ deviceId: 1,
+ listEntry: { id: 1 },
+ specificationResponse: null,
+ detailsResponse: { result: { local_key: 'localKey' } },
+ propertiesResponse: { result: { dps: { 1: true } } },
+ modelResponse: { result: { model: '{"services":[]}' } },
+ specificationError: 'spec failure',
+ specifications: {},
+ properties: { dps: { 1: true } },
+ thingModel: { services: [] },
+ }),
+ });
+ expect(warnStub.calledOnce).to.equal(true);
+ expect(warnStub.firstCall.args[0]).to.match(/Failed to load specifications/);
+ });
+
+ it('should warn when details loading fails', async () => {
+ const warnStub = sinon.stub(logger, 'warn');
+ tuyaHandler.connector.request = sinon
+ .stub()
+ .onCall(0)
+ .resolves({ result: { details: 'specification' } })
+ .onCall(1)
+ .rejects(new Error('details failure'))
+ .onCall(2)
+ .resolves({ result: { dps: { 1: true } } })
+ .onCall(3)
+ .resolves({ result: { model: '{"services":[]}' } });
+
+ const device = await tuyaHandler.loadDeviceDetails({ id: 1 });
+
+ expect(device).to.deep.eq({
+ id: 1,
+ specifications: { details: 'specification' },
+ properties: { dps: { 1: true } },
+ thing_model: { services: [] },
+ tuya_report: buildExpectedReport({
+ deviceId: 1,
+ listEntry: { id: 1 },
+ specificationResponse: { result: { details: 'specification' } },
+ detailsResponse: null,
+ propertiesResponse: { result: { dps: { 1: true } } },
+ modelResponse: { result: { model: '{"services":[]}' } },
+ detailsError: 'details failure',
+ specifications: { details: 'specification' },
+ properties: { dps: { 1: true } },
+ thingModel: { services: [] },
+ }),
+ });
+ expect(warnStub.calledOnce).to.equal(true);
+ expect(warnStub.firstCall.args[0]).to.match(/Failed to load details/);
+ });
+
+ it('should warn when properties and model loading fails', async () => {
+ const warnStub = sinon.stub(logger, 'warn');
+ tuyaHandler.connector.request = sinon
+ .stub()
+ .onCall(0)
+ .resolves({ result: { details: 'specification' } })
+ .onCall(1)
+ .resolves({ result: { local_key: 'localKey' } })
+ .onCall(2)
+ .rejects(new Error('props failure'))
+ .onCall(3)
+ .rejects(new Error('model failure'));
+
+ const device = await tuyaHandler.loadDeviceDetails({ id: 1 });
+
+ expect(device).to.deep.eq({
+ id: 1,
+ local_key: 'localKey',
+ specifications: { details: 'specification' },
+ properties: {},
+ thing_model: null,
+ tuya_report: buildExpectedReport({
+ deviceId: 1,
+ listEntry: { id: 1 },
+ specificationResponse: { result: { details: 'specification' } },
+ detailsResponse: { result: { local_key: 'localKey' } },
+ propertiesResponse: null,
+ modelResponse: null,
+ propertiesError: 'props failure',
+ modelError: 'model failure',
+ specifications: { details: 'specification' },
+ properties: {},
+ thingModel: null,
+ }),
+ });
+ expect(warnStub.callCount).to.equal(2);
+ expect(warnStub.firstCall.args[0]).to.match(/Failed to load properties/);
+ expect(warnStub.secondCall.args[0]).to.match(/Failed to load thing model/);
+ });
+
+ it('should handle invalid thing model json', async () => {
+ tuyaHandler.connector.request = sinon
+ .stub()
+ .onCall(0)
+ .resolves({ result: { details: 'specification' } })
+ .onCall(1)
+ .resolves({ result: { local_key: 'localKey' } })
+ .onCall(2)
+ .resolves({ result: { dps: { 1: true } } })
+ .onCall(3)
+ .resolves({ result: { model: 'not-json' } });
+
+ const device = await tuyaHandler.loadDeviceDetails({ id: 1 });
+
+ expect(device).to.deep.eq({
+ id: 1,
+ local_key: 'localKey',
+ specifications: { details: 'specification' },
+ properties: { dps: { 1: true } },
+ thing_model: null,
+ tuya_report: buildExpectedReport({
+ deviceId: 1,
+ listEntry: { id: 1 },
+ specificationResponse: { result: { details: 'specification' } },
+ detailsResponse: { result: { local_key: 'localKey' } },
+ propertiesResponse: { result: { dps: { 1: true } } },
+ modelResponse: { result: { model: 'not-json' } },
+ specifications: { details: 'specification' },
+ properties: { dps: { 1: true } },
+ thingModel: null,
+ }),
+ });
+ });
+
+ it('should keep thing model when model is an object', async () => {
+ tuyaHandler.connector.request = sinon
+ .stub()
+ .onCall(0)
+ .resolves({ result: { details: 'specification' } })
+ .onCall(1)
+ .resolves({ result: { local_key: 'localKey' } })
+ .onCall(2)
+ .resolves({ result: { dps: { 1: true } } })
+ .onCall(3)
+ .resolves({ result: { services: [] } });
+
+ const device = await tuyaHandler.loadDeviceDetails({ id: 1 });
+
+ expect(device).to.deep.eq({
+ id: 1,
+ local_key: 'localKey',
+ specifications: { details: 'specification' },
+ properties: { dps: { 1: true } },
+ thing_model: { services: [] },
+ tuya_report: buildExpectedReport({
+ deviceId: 1,
+ listEntry: { id: 1 },
+ specificationResponse: { result: { details: 'specification' } },
+ detailsResponse: { result: { local_key: 'localKey' } },
+ propertiesResponse: { result: { dps: { 1: true } } },
+ modelResponse: { result: { services: [] } },
+ specifications: { details: 'specification' },
+ properties: { dps: { 1: true } },
+ thingModel: { services: [] },
+ }),
+ });
+ });
+
+ it('should handle fulfilled responses with null values', async () => {
+ tuyaHandler.connector.request = sinon
+ .stub()
+ .onCall(0)
+ .resolves(null)
+ .onCall(1)
+ .resolves(null)
+ .onCall(2)
+ .resolves(null)
+ .onCall(3)
+ .resolves(null);
+
+ const device = await tuyaHandler.loadDeviceDetails({ id: 1 });
+
+ expect(device).to.deep.eq({
+ id: 1,
+ specifications: {},
+ properties: {},
+ thing_model: null,
+ tuya_report: buildExpectedReport({
+ deviceId: 1,
+ listEntry: { id: 1 },
+ specificationResponse: null,
+ detailsResponse: null,
+ propertiesResponse: null,
+ modelResponse: null,
+ specifications: {},
+ properties: {},
+ thingModel: null,
+ }),
+ });
+ });
+
+ it('should use raw rejection reasons when requests reject without Error instances', async () => {
+ const warnStub = sinon.stub(logger, 'warn');
+ tuyaHandler.connector.request = sinon
+ .stub()
+ .onCall(0)
+ .rejects('specification failure')
+ .onCall(1)
+ .rejects('details failure')
+ .onCall(2)
+ .rejects('properties failure')
+ .onCall(3)
+ .rejects('model failure');
+
+ const device = await tuyaHandler.loadDeviceDetails({ id: 1 });
+
+ expect(device.id).to.equal(1);
+ expect(device.specifications).to.deep.equal({});
+ expect(device.properties).to.deep.equal({});
+ expect(device.thing_model).to.equal(null);
+ expect(device.tuya_report.cloud.assembled).to.deep.equal({
+ specifications: {},
+ properties: {},
+ thing_model: null,
+ });
+ expect(String(device.tuya_report.cloud.raw.device_specification.error)).to.equal('specification failure');
+ expect(String(device.tuya_report.cloud.raw.device_details.error)).to.equal('details failure');
+ expect(String(device.tuya_report.cloud.raw.thing_shadow_properties.error)).to.equal('properties failure');
+ expect(String(device.tuya_report.cloud.raw.thing_model.error)).to.equal('model failure');
+ expect(warnStub.callCount).to.equal(4);
+ });
+
+ it('should handle fulfilled responses without result payload', async () => {
+ tuyaHandler.connector.request = sinon.stub().resolves({});
+
+ const device = await tuyaHandler.loadDeviceDetails({ id: 1, category: 'switch' });
+
+ expect(device).to.deep.eq({
+ id: 1,
+ category: 'switch',
+ specifications: { category: 'switch' },
+ properties: {},
+ thing_model: null,
+ tuya_report: buildExpectedReport({
+ deviceId: 1,
+ listEntry: { id: 1, category: 'switch' },
+ specificationResponse: {},
+ detailsResponse: {},
+ propertiesResponse: {},
+ modelResponse: {},
+ specifications: { category: 'switch' },
+ properties: {},
+ thingModel: null,
+ }),
+ });
});
});
diff --git a/server/test/services/tuya/lib/tuya.loadDevices.test.js b/server/test/services/tuya/lib/tuya.loadDevices.test.js
index 2ee6cd9d84..36d4646987 100644
--- a/server/test/services/tuya/lib/tuya.loadDevices.test.js
+++ b/server/test/services/tuya/lib/tuya.loadDevices.test.js
@@ -18,13 +18,14 @@ describe('TuyaHandler.loadDevices', () => {
beforeEach(() => {
sinon.reset();
+ gladys.variable.getValue = fake.resolves('APP_ACCOUNT_UID');
tuyaHandler.connector = {
request: sinon
.stub()
.onFirstCall()
- .resolves({ result: { list: [{ id: 1 }], total: 2, has_more: true, last_row_key: 'next' } })
+ .resolves({ result: { list: [{ id: 1 }], has_more: true } })
.onSecondCall()
- .resolves({ result: { list: [{ id: 2 }], total: 2, has_more: false } }),
+ .resolves({ result: { list: [{ id: 2 }], has_more: false } }),
};
});
@@ -33,20 +34,152 @@ describe('TuyaHandler.loadDevices', () => {
});
it('should loop on pages', async () => {
- const devices = await tuyaHandler.loadDevices();
+ const devices = await tuyaHandler.loadDevices(1, 1);
expect(devices).to.deep.eq([{ id: 1 }, { id: 2 }]);
assert.callCount(tuyaHandler.connector.request, 2);
assert.calledWith(tuyaHandler.connector.request, {
method: 'GET',
- path: `${API.VERSION_1_3}/devices`,
- query: { last_row_key: null, source_id: 'APP_ACCOUNT_UID', source_type: 'tuyaUser' },
+ path: `${API.PUBLIC_VERSION_1_0}/users/APP_ACCOUNT_UID/devices`,
+ query: { page_no: 1, page_size: 1 },
});
assert.calledWith(tuyaHandler.connector.request, {
method: 'GET',
- path: `${API.VERSION_1_3}/devices`,
- query: { last_row_key: 'next', source_id: 'APP_ACCOUNT_UID', source_type: 'tuyaUser' },
+ path: `${API.PUBLIC_VERSION_1_0}/users/APP_ACCOUNT_UID/devices`,
+ query: { page_no: 2, page_size: 1 },
});
});
+
+ it('should loop on pages with array result', async () => {
+ tuyaHandler.connector.request = sinon
+ .stub()
+ .onFirstCall()
+ .resolves({ result: [{ id: 1 }] })
+ .onSecondCall()
+ .resolves({ result: [] });
+
+ const devices = await tuyaHandler.loadDevices(1, 1);
+
+ expect(devices).to.deep.eq([{ id: 1 }]);
+ assert.callCount(tuyaHandler.connector.request, 2);
+ });
+
+ it('should throw on invalid pageNo', async () => {
+ try {
+ await tuyaHandler.loadDevices(0, 1);
+ assert.fail();
+ } catch (e) {
+ expect(e.message).to.equal('pageNo must be a positive integer');
+ }
+ });
+
+ it('should throw on invalid pageSize', async () => {
+ try {
+ await tuyaHandler.loadDevices(1, 0);
+ assert.fail();
+ } catch (e) {
+ expect(e.message).to.equal('pageSize must be a positive integer');
+ }
+ });
+
+ it('should throw on api error response', async () => {
+ tuyaHandler.connector.request = sinon.stub().resolves({
+ success: false,
+ msg: 'Tuya error',
+ });
+
+ try {
+ await tuyaHandler.loadDevices(1, 1);
+ assert.fail();
+ } catch (e) {
+ expect(e.message).to.equal('Tuya error');
+ }
+ });
+
+ it('should use error message field when msg is missing', async () => {
+ tuyaHandler.connector.request = sinon.stub().resolves({
+ success: false,
+ message: 'Tuya message error',
+ });
+
+ try {
+ await tuyaHandler.loadDevices(1, 1);
+ assert.fail();
+ } catch (e) {
+ expect(e.message).to.equal('Tuya message error');
+ }
+ });
+
+ it('should use error code field when msg and message are missing', async () => {
+ tuyaHandler.connector.request = sinon.stub().resolves({
+ success: false,
+ code: 'TUYA_ERR_CODE',
+ });
+
+ try {
+ await tuyaHandler.loadDevices(1, 1);
+ assert.fail();
+ } catch (e) {
+ expect(e.message).to.equal('TUYA_ERR_CODE');
+ }
+ });
+
+ it('should fallback to default api error message when error payload is empty', async () => {
+ tuyaHandler.connector.request = sinon.stub().resolves({
+ success: false,
+ });
+
+ try {
+ await tuyaHandler.loadDevices(1, 1);
+ assert.fail();
+ } catch (e) {
+ expect(e.message).to.equal('Tuya API error');
+ }
+ });
+
+ it('should throw on empty api response', async () => {
+ tuyaHandler.connector.request = sinon.stub().resolves(null);
+
+ try {
+ await tuyaHandler.loadDevices(1, 1);
+ assert.fail();
+ } catch (e) {
+ expect(e.message).to.equal('Tuya API returned no response');
+ }
+ });
+
+ it('should throw when app account uid is missing', async () => {
+ gladys.variable.getValue = sinon.fake.resolves(null);
+
+ try {
+ await tuyaHandler.loadDevices(1, 1);
+ assert.fail();
+ } catch (e) {
+ expect(e.message).to.equal('Tuya APP_ACCOUNT_UID is missing');
+ }
+ });
+
+ it('should throw when pagination does not advance', async () => {
+ tuyaHandler.connector.request = sinon.stub().resolves({
+ result: { list: [], has_more: true },
+ });
+
+ try {
+ await tuyaHandler.loadDevices(1, 1);
+ assert.fail();
+ } catch (e) {
+ expect(e.message).to.equal('Tuya API pagination did not advance (has_more=true with empty page)');
+ }
+ });
+
+ it('should handle malformed result.list payloads as empty pages', async () => {
+ tuyaHandler.connector.request = sinon.stub().resolves({
+ result: { list: { id: 1 }, has_more: false },
+ });
+
+ const devices = await tuyaHandler.loadDevices(1, 1);
+
+ expect(devices).to.deep.eq([]);
+ });
});
diff --git a/server/test/services/tuya/lib/tuya.localPoll.test.js b/server/test/services/tuya/lib/tuya.localPoll.test.js
new file mode 100644
index 0000000000..7a458e5062
--- /dev/null
+++ b/server/test/services/tuya/lib/tuya.localPoll.test.js
@@ -0,0 +1,590 @@
+/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */
+const sinon = require('sinon');
+const { expect } = require('chai');
+const proxyquire = require('proxyquire').noCallThru();
+const { BadParameters } = require('../../../../utils/coreErrors');
+const { DEVICE_PARAM_NAME } = require('../../../../services/tuya/lib/utils/tuya.constants');
+const { updateDiscoveredDeviceAfterLocalPoll } = require('../../../../services/tuya/lib/tuya.localPoll');
+
+const attachEventHandlers = (instance) => {
+ instance.on = sinon.stub();
+ instance.once = sinon.stub();
+ instance.removeListener = sinon.stub();
+};
+
+describe('TuyaHandler.localPoll', () => {
+ it('should throw if missing parameters', async () => {
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: function TuyAPIStub() {},
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+ try {
+ await localPoll({});
+ } catch (e) {
+ expect(e).to.be.instanceOf(BadParameters);
+ expect(e.message).to.equal('Missing local connection parameters');
+ return;
+ }
+ throw new Error('Expected error');
+ });
+
+ it('should return dps on success', async () => {
+ const connect = sinon.stub().resolves();
+ const get = sinon.stub().resolves({ dps: { 1: true } });
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.get = get;
+ this.disconnect = disconnect;
+ attachEventHandlers(this);
+ }
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+ const result = await localPoll({
+ deviceId: 'device',
+ ip: '1.1.1.1',
+ localKey: 'key',
+ protocolVersion: '3.3',
+ });
+ expect(result).to.deep.equal({ dps: { 1: true } });
+ expect(connect.calledOnce).to.equal(true);
+ expect(get.calledOnce).to.equal(true);
+ expect(disconnect.calledOnce).to.equal(true);
+ });
+
+ it('should throw on invalid response', async () => {
+ const connect = sinon.stub().resolves();
+ const get = sinon.stub().resolves('parse data error');
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.get = get;
+ this.disconnect = disconnect;
+ attachEventHandlers(this);
+ }
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+ try {
+ await localPoll({
+ deviceId: 'device',
+ ip: '1.1.1.1',
+ localKey: 'key',
+ protocolVersion: '3.3',
+ });
+ } catch (e) {
+ expect(e).to.be.instanceOf(BadParameters);
+ expect(e.message).to.include('Invalid local poll response');
+ return;
+ }
+ throw new Error('Expected error');
+ });
+
+ it('should try multiple attempts for protocol 3.5', async () => {
+ const connect = sinon.stub().resolves();
+ const get = sinon
+ .stub()
+ .onFirstCall()
+ .rejects(new Error('fail'))
+ .onSecondCall()
+ .resolves({ dps: { 1: true } });
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ throw new Error('tuyapi should not be used for protocol 3.5');
+ }
+ function TuyAPINewGenStub() {
+ this.connect = connect;
+ this.get = get;
+ this.disconnect = disconnect;
+ attachEventHandlers(this);
+ }
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': TuyAPINewGenStub,
+ });
+ const result = await localPoll({
+ deviceId: 'device',
+ ip: '1.1.1.1',
+ localKey: 'key',
+ protocolVersion: '3.5',
+ timeoutMs: 1000,
+ });
+ expect(result).to.deep.equal({ dps: { 1: true } });
+ expect(get.calledTwice).to.equal(true);
+ });
+
+ it('should retry once for protocol 3.4', async () => {
+ const connect = sinon.stub().resolves();
+ const get = sinon
+ .stub()
+ .onFirstCall()
+ .rejects(new Error('fail'))
+ .onSecondCall()
+ .resolves({ dps: { 1: true } });
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.get = get;
+ this.disconnect = disconnect;
+ attachEventHandlers(this);
+ }
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+ const result = await localPoll({
+ deviceId: 'device',
+ ip: '1.1.1.1',
+ localKey: 'key',
+ protocolVersion: '3.4',
+ timeoutMs: 1000,
+ });
+ expect(result).to.deep.equal({ dps: { 1: true } });
+ expect(get.calledTwice).to.equal(true);
+ });
+
+ it('should throw on object without dps', async () => {
+ const connect = sinon.stub().resolves();
+ const get = sinon.stub().resolves({ ok: true });
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.get = get;
+ this.disconnect = disconnect;
+ attachEventHandlers(this);
+ }
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+ try {
+ await localPoll({
+ deviceId: 'device',
+ ip: '1.1.1.1',
+ localKey: 'key',
+ protocolVersion: '3.3',
+ });
+ } catch (e) {
+ expect(e).to.be.instanceOf(BadParameters);
+ expect(e.message).to.equal('Invalid local poll response');
+ return;
+ }
+ throw new Error('Expected error');
+ });
+
+ it('should timeout', async () => {
+ const clock = sinon.useFakeTimers();
+ try {
+ const connect = sinon.stub().resolves();
+ const get = sinon.stub().returns(new Promise(() => {}));
+ const disconnect = sinon.stub().resolves();
+ const TuyAPIStub = function TuyAPIStub() {
+ this.connect = connect;
+ this.get = get;
+ this.disconnect = disconnect;
+ attachEventHandlers(this);
+ };
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+ const promise = localPoll({
+ deviceId: 'device',
+ ip: '1.1.1.1',
+ localKey: 'key',
+ protocolVersion: '3.3',
+ timeoutMs: 1000,
+ });
+ // Attach handler immediately to avoid PromiseRejectionHandledWarning with fake timers.
+ const errorPromise = (async () => {
+ try {
+ await promise;
+ return null;
+ } catch (error) {
+ return error;
+ }
+ })();
+ await clock.tickAsync(1100);
+ const error = await errorPromise;
+ expect(error).to.be.instanceOf(BadParameters);
+ expect(error.message).to.equal('Local poll timeout');
+ } finally {
+ clock.restore();
+ }
+ });
+
+ it('should sanitize too-low timeoutMs before timing out', async () => {
+ const clock = sinon.useFakeTimers();
+ try {
+ const connect = sinon.stub().resolves();
+ const get = sinon.stub().returns(new Promise(() => {}));
+ const disconnect = sinon.stub().resolves();
+ const TuyAPIStub = function TuyAPIStub() {
+ this.connect = connect;
+ this.get = get;
+ this.disconnect = disconnect;
+ attachEventHandlers(this);
+ };
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+ const promise = localPoll({
+ deviceId: 'device',
+ ip: '1.1.1.1',
+ localKey: 'key',
+ protocolVersion: '3.3',
+ timeoutMs: 10,
+ });
+ const errorPromise = (async () => {
+ try {
+ await promise;
+ return null;
+ } catch (error) {
+ return error;
+ }
+ })();
+ await clock.tickAsync(400);
+ expect(await Promise.race([errorPromise, Promise.resolve('pending')])).to.equal('pending');
+ await clock.tickAsync(100);
+ const error = await errorPromise;
+ expect(error).to.be.instanceOf(BadParameters);
+ expect(error.message).to.equal('Local poll timeout');
+ } finally {
+ clock.restore();
+ }
+ });
+
+ it('should reject on socket error listener', async () => {
+ const connect = sinon.stub().resolves();
+ const get = sinon.stub().returns(new Promise(() => {}));
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.get = get;
+ this.disconnect = disconnect;
+ this.on = sinon.stub();
+ this.once = sinon.stub().callsFake((event, cb) => {
+ if (event === 'error') {
+ cb(new Error('boom'));
+ }
+ });
+ this.removeListener = sinon.stub();
+ }
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+
+ try {
+ await localPoll({
+ deviceId: 'device',
+ ip: '1.1.1.1',
+ localKey: 'key',
+ protocolVersion: '3.3',
+ });
+ } catch (e) {
+ expect(e).to.be.instanceOf(BadParameters);
+ expect(e.message).to.include('Local poll socket error');
+ return;
+ }
+ throw new Error('Expected error');
+ });
+
+ it('should normalize unreachable local device socket errors', async () => {
+ const connect = sinon.stub().resolves();
+ const get = sinon.stub().returns(new Promise(() => {}));
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.get = get;
+ this.disconnect = disconnect;
+ this.on = sinon.stub();
+ this.once = sinon.stub().callsFake((event, cb) => {
+ if (event === 'error') {
+ const error = new Error('Error from socket: connect EHOSTUNREACH 10.1.0.53:6668');
+ error.code = 'EHOSTUNREACH';
+ cb(error);
+ }
+ });
+ this.removeListener = sinon.stub();
+ }
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+
+ try {
+ await localPoll({
+ deviceId: 'device',
+ ip: '10.1.0.53',
+ localKey: 'key',
+ protocolVersion: '3.3',
+ });
+ } catch (e) {
+ expect(e).to.be.instanceOf(BadParameters);
+ expect(e.message).to.equal(
+ 'Local device unreachable at 10.1.0.53:6668 (EHOSTUNREACH). Device may be offline, unplugged, or no longer connected to Wi-Fi.',
+ );
+ return;
+ }
+ throw new Error('Expected error');
+ });
+
+ it('should keep the original connect error when a socket error also occurs', async () => {
+ const connect = sinon.stub().rejects(new Error('connect failed'));
+ const get = sinon.stub();
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.get = get;
+ this.disconnect = disconnect;
+ this.once = sinon.stub();
+ this.removeListener = sinon.stub();
+ this.on = sinon.stub().callsFake((event, cb) => {
+ if (event === 'error') {
+ cb(new Error('socket boom'));
+ }
+ });
+ }
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+
+ try {
+ await localPoll({
+ deviceId: 'device',
+ ip: '1.1.1.1',
+ localKey: 'key',
+ protocolVersion: '3.3',
+ });
+ } catch (e) {
+ expect(e.message).to.equal('connect failed');
+ return;
+ }
+ throw new Error('Expected error');
+ });
+
+ it('should propagate cleanup errors after a successful poll', async () => {
+ const connect = sinon.stub().resolves();
+ const get = sinon.stub().resolves({ dps: { 1: true } });
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.get = get;
+ this.disconnect = disconnect;
+ this.on = sinon.stub();
+ this.once = sinon.stub();
+ this.removeListener = sinon.stub().throws(new Error('removeListener error'));
+ }
+ const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+
+ try {
+ await localPoll({
+ deviceId: 'device',
+ ip: '1.1.1.1',
+ localKey: 'key',
+ protocolVersion: '3.3',
+ });
+ } catch (e) {
+ expect(e.message).to.equal('removeListener error');
+ return;
+ }
+ throw new Error('Expected error');
+ });
+});
+
+describe('TuyaHandler.updateDiscoveredDeviceAfterLocalPoll', () => {
+ it('should return null when payload is missing deviceId', () => {
+ const result = updateDiscoveredDeviceAfterLocalPoll({ discoveredDevices: [] }, {});
+ expect(result).to.equal(null);
+ });
+
+ it('should return null when discovered devices is not an array', () => {
+ const result = updateDiscoveredDeviceAfterLocalPoll({ discoveredDevices: null }, { deviceId: 'device' });
+ expect(result).to.equal(null);
+ });
+
+ it('should return null when device is not found', () => {
+ const tuyaManager = { discoveredDevices: [{ external_id: 'tuya:other' }] };
+ const result = updateDiscoveredDeviceAfterLocalPoll(tuyaManager, { deviceId: 'device' });
+ expect(result).to.equal(null);
+ });
+
+ it('should update discovered device with local poll data', () => {
+ const tuyaManager = {
+ discoveredDevices: [
+ {
+ external_id: 'tuya:device1',
+ params: [],
+ product_id: 'pid',
+ product_key: 'pkey',
+ tuya_report: {
+ schema_version: 2,
+ cloud: {
+ assembled: {
+ specifications: null,
+ properties: null,
+ thing_model: null,
+ },
+ raw: {
+ device_list_entry: null,
+ device_specification: null,
+ device_details: null,
+ thing_shadow_properties: null,
+ thing_model: null,
+ },
+ },
+ local: {
+ scan: {
+ source: 'udp',
+ response: { ip: '9.9.9.9', version: '3.3' },
+ },
+ },
+ },
+ },
+ ],
+ };
+
+ const updated = updateDiscoveredDeviceAfterLocalPoll(tuyaManager, {
+ deviceId: 'device1',
+ ip: '1.1.1.1',
+ protocolVersion: '3.3',
+ localKey: 'key',
+ });
+
+ expect(updated.local_override).to.equal(true);
+ expect(updated.ip).to.equal('1.1.1.1');
+ const { params } = updated;
+ const findParam = (name) => params.find((param) => param.name === name);
+ expect(findParam(DEVICE_PARAM_NAME.IP_ADDRESS).value).to.equal('1.1.1.1');
+ expect(findParam(DEVICE_PARAM_NAME.PROTOCOL_VERSION).value).to.equal('3.3');
+ expect(findParam(DEVICE_PARAM_NAME.LOCAL_KEY).value).to.equal('key');
+ expect(findParam(DEVICE_PARAM_NAME.LOCAL_OVERRIDE).value).to.equal(true);
+ expect(findParam(DEVICE_PARAM_NAME.PRODUCT_ID).value).to.equal('pid');
+ expect(findParam(DEVICE_PARAM_NAME.PRODUCT_KEY).value).to.equal('pkey');
+ expect(updated.tuya_report.local.scan).to.deep.equal({
+ source: 'udp',
+ response: { ip: '9.9.9.9', version: '3.3' },
+ });
+ });
+
+ it('should merge when gladys stateManager exists', () => {
+ const tuyaManager = {
+ discoveredDevices: [
+ {
+ external_id: 'tuya:device1',
+ params: [],
+ features: [],
+ },
+ ],
+ gladys: {
+ stateManager: {
+ get: sinon.stub().returns({
+ external_id: 'tuya:device1',
+ params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: '1' }],
+ features: [],
+ }),
+ },
+ },
+ };
+
+ const updated = updateDiscoveredDeviceAfterLocalPoll(tuyaManager, {
+ deviceId: 'device1',
+ ip: '1.1.1.1',
+ protocolVersion: '3.3',
+ });
+
+ expect(updated).to.have.property('updatable');
+ expect(updated.local_override).to.equal(true);
+ });
+
+ it('should add fallback binary feature when dps includes key 1', () => {
+ const tuyaManager = {
+ discoveredDevices: [
+ {
+ external_id: 'tuya:device1',
+ params: [],
+ features: [],
+ },
+ ],
+ };
+
+ const updated = updateDiscoveredDeviceAfterLocalPoll(tuyaManager, {
+ deviceId: 'device1',
+ ip: '1.1.1.1',
+ protocolVersion: '3.3',
+ dps: { 1: true },
+ });
+
+ expect(updated.features).to.have.length(1);
+ expect(updated.features[0].external_id).to.equal('tuya:device1:switch_1');
+ });
+
+ it('should rebuild a supported device from existing metadata after local poll', () => {
+ const tuyaManager = {
+ serviceId: 'tuya-service-id',
+ discoveredDevices: [
+ {
+ external_id: 'tuya:device1',
+ model: 'Smart Meter',
+ product_id: 'bbcg1hrkrj5rifsd',
+ params: [],
+ features: [],
+ specifications: {},
+ properties: {
+ properties: [{ code: 'total_power', dp_id: 115, value: 706 }],
+ },
+ },
+ ],
+ };
+
+ const updated = updateDiscoveredDeviceAfterLocalPoll(tuyaManager, {
+ deviceId: 'device1',
+ ip: '1.1.1.1',
+ protocolVersion: '3.5',
+ dps: { 115: 706 },
+ });
+
+ expect(updated.device_type).to.equal('smart-meter');
+ expect(updated.features.length).to.be.greaterThan(0);
+ expect(updated.features.some((feature) => feature.external_id === 'tuya:device1:total_power')).to.equal(true);
+ });
+
+ it('should reuse PRODUCT_ID from params when top-level product_id is missing', () => {
+ const tuyaManager = {
+ serviceId: 'tuya-service-id',
+ discoveredDevices: [
+ {
+ external_id: 'tuya:device1',
+ model: 'Smart Meter',
+ product_id: undefined,
+ params: [{ name: DEVICE_PARAM_NAME.PRODUCT_ID, value: 'bbcg1hrkrj5rifsd' }],
+ features: [],
+ specifications: {},
+ properties: {
+ properties: [{ code: 'total_power', dp_id: 115, value: 706 }],
+ },
+ },
+ ],
+ };
+
+ const updated = updateDiscoveredDeviceAfterLocalPoll(tuyaManager, {
+ deviceId: 'device1',
+ ip: '1.1.1.1',
+ protocolVersion: '3.5',
+ dps: { 115: 706 },
+ });
+
+ expect(updated.product_id).to.equal('bbcg1hrkrj5rifsd');
+ expect(updated.device_type).to.equal('smart-meter');
+ expect(updated.features.length).to.be.greaterThan(0);
+ });
+});
diff --git a/server/test/services/tuya/lib/tuya.localScan.test.js b/server/test/services/tuya/lib/tuya.localScan.test.js
new file mode 100644
index 0000000000..3976108e79
--- /dev/null
+++ b/server/test/services/tuya/lib/tuya.localScan.test.js
@@ -0,0 +1,746 @@
+/* eslint-disable require-jsdoc, jsdoc/require-jsdoc, class-methods-use-this */
+const sinon = require('sinon');
+const { expect } = require('chai');
+const proxyquire = require('proxyquire').noCallThru();
+const { mergeTuyaReport } = require('../../../../services/tuya/lib/utils/tuya.report');
+
+describe('TuyaHandler.localScan', () => {
+ it('should return discovered devices from udp payload', async () => {
+ const sockets = [];
+ const dgramStub = {
+ createSocket: () => {
+ const handlers = {};
+ const socket = {
+ on: (event, cb) => {
+ handlers[event] = cb;
+ },
+ bind: (options, cb) => {
+ if (typeof options === 'function') {
+ options();
+ return;
+ }
+ if (cb) {
+ cb();
+ }
+ },
+ close: () => {},
+ handlers,
+ };
+ sockets.push(socket);
+ return socket;
+ },
+ };
+
+ class MessageParserStub {
+ parse() {
+ return [
+ {
+ payload: {
+ gwId: 'device-id',
+ ip: '1.1.1.1',
+ version: '3.3',
+ productKey: 'product-key',
+ },
+ },
+ ];
+ }
+ }
+
+ const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ dgram: dgramStub,
+ '@demirdeniz/tuyapi-newgen/lib/message-parser': { MessageParser: MessageParserStub },
+ '@demirdeniz/tuyapi-newgen/lib/config': { UDP_KEY: 'key' },
+ });
+
+ const clock = sinon.useFakeTimers();
+ const promise = localScan(1);
+
+ // Trigger message on all sockets
+ sockets.forEach((socket) => {
+ if (socket.handlers.message) {
+ socket.handlers.message(Buffer.from('test'));
+ }
+ });
+
+ await clock.tickAsync(1100);
+ const result = await promise;
+ clock.restore();
+
+ expect(result).to.deep.equal({
+ devices: {
+ 'device-id': {
+ ip: '1.1.1.1',
+ version: '3.3',
+ productKey: 'product-key',
+ },
+ },
+ portErrors: {},
+ });
+ });
+
+ it('should ignore invalid payloads and handle socket errors', async () => {
+ const sockets = [];
+ const dgramStub = {
+ createSocket: () => {
+ const handlers = {};
+ const socket = {
+ on: (event, cb) => {
+ handlers[event] = cb;
+ },
+ bind: () => {},
+ close: () => {
+ throw new Error('close error');
+ },
+ handlers,
+ };
+ sockets.push(socket);
+ return socket;
+ },
+ };
+
+ let callIndex = 0;
+ class MessageParserStub {
+ parse() {
+ callIndex += 1;
+ if (callIndex === 1) {
+ throw new Error('bad payload');
+ }
+ if (callIndex === 2) {
+ return [{ payload: 'invalid' }];
+ }
+ return [{ payload: { ip: '1.1.1.1' } }];
+ }
+ }
+
+ const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ dgram: dgramStub,
+ '@demirdeniz/tuyapi-newgen/lib/message-parser': { MessageParser: MessageParserStub },
+ '@demirdeniz/tuyapi-newgen/lib/config': { UDP_KEY: 'key' },
+ });
+
+ const clock = sinon.useFakeTimers();
+ const promise = localScan(1);
+
+ sockets.forEach((socket) => {
+ if (socket.handlers.message) {
+ socket.handlers.message(Buffer.from('test'));
+ socket.handlers.message(Buffer.from('test'));
+ socket.handlers.message(Buffer.from('test'));
+ }
+ if (socket.handlers.error) {
+ socket.handlers.error(new Error('boom'));
+ }
+ });
+
+ await clock.tickAsync(1100);
+ const result = await promise;
+ clock.restore();
+
+ expect(result).to.deep.equal({
+ devices: {},
+ portErrors: {
+ 6666: 'boom',
+ 6667: 'boom',
+ 7000: 'boom',
+ },
+ });
+ });
+
+ it('should preserve richer fields when duplicate device packets are sparse', async () => {
+ const sockets = [];
+ const dgramStub = {
+ createSocket: () => {
+ const handlers = {};
+ const socket = {
+ on: (event, cb) => {
+ handlers[event] = cb;
+ },
+ bind: (options, cb) => {
+ if (typeof options === 'function') {
+ options();
+ return;
+ }
+ if (cb) {
+ cb();
+ }
+ },
+ close: () => {},
+ handlers,
+ };
+ sockets.push(socket);
+ return socket;
+ },
+ };
+
+ let callIndex = 0;
+ class MessageParserStub {
+ parse() {
+ callIndex += 1;
+ if (callIndex === 1) {
+ return [{ payload: { gwId: 'device-id', ip: '1.1.1.1', version: '3.3', productKey: 'product-key' } }];
+ }
+ return [{ payload: { gwId: 'device-id' } }];
+ }
+ }
+
+ const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ dgram: dgramStub,
+ '@demirdeniz/tuyapi-newgen/lib/message-parser': { MessageParser: MessageParserStub },
+ '@demirdeniz/tuyapi-newgen/lib/config': { UDP_KEY: 'key' },
+ });
+
+ const clock = sinon.useFakeTimers();
+ const promise = localScan(1);
+
+ sockets.forEach((socket) => {
+ if (socket.handlers.message) {
+ socket.handlers.message(Buffer.from('first'));
+ socket.handlers.message(Buffer.from('second'));
+ }
+ });
+
+ await clock.tickAsync(1100);
+ const result = await promise;
+ clock.restore();
+
+ expect(result).to.deep.equal({
+ devices: {
+ 'device-id': {
+ ip: '1.1.1.1',
+ version: '3.3',
+ productKey: 'product-key',
+ },
+ },
+ portErrors: {},
+ });
+ });
+
+ it('should skip message when all parsers fail', async () => {
+ const sockets = [];
+ const dgramStub = {
+ createSocket: () => {
+ const handlers = {};
+ const socket = {
+ on: (event, cb) => {
+ handlers[event] = cb;
+ },
+ bind: () => {},
+ close: () => {},
+ handlers,
+ };
+ sockets.push(socket);
+ return socket;
+ },
+ };
+
+ class MessageParserStub {
+ parse() {
+ throw new Error('invalid packet');
+ }
+ }
+
+ const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ dgram: dgramStub,
+ '@demirdeniz/tuyapi-newgen/lib/message-parser': { MessageParser: MessageParserStub },
+ '@demirdeniz/tuyapi-newgen/lib/config': { UDP_KEY: 'key' },
+ });
+
+ const clock = sinon.useFakeTimers();
+ const promise = localScan(1);
+
+ sockets.forEach((socket) => {
+ if (socket.handlers.message) {
+ socket.handlers.message(Buffer.from('bad'));
+ }
+ });
+
+ await clock.tickAsync(1100);
+ const result = await promise;
+ clock.restore();
+
+ expect(result).to.deep.equal({
+ devices: {},
+ portErrors: {},
+ });
+ });
+
+ it('should handle socket address errors on bind', async () => {
+ const sockets = [];
+ const dgramStub = {
+ createSocket: () => {
+ const handlers = {};
+ const socket = {
+ on: (event, cb) => {
+ handlers[event] = cb;
+ },
+ bind: (options, cb) => {
+ if (handlers.listening) {
+ handlers.listening();
+ }
+ if (typeof options === 'function') {
+ options();
+ return;
+ }
+ if (cb) {
+ cb();
+ }
+ },
+ close: () => {},
+ address: () => {
+ throw new Error('address error');
+ },
+ handlers,
+ };
+ sockets.push(socket);
+ return socket;
+ },
+ };
+
+ class MessageParserStub {
+ parse() {
+ return [{ payload: null }];
+ }
+ }
+
+ const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ dgram: dgramStub,
+ '@demirdeniz/tuyapi-newgen/lib/message-parser': { MessageParser: MessageParserStub },
+ '@demirdeniz/tuyapi-newgen/lib/config': { UDP_KEY: 'key' },
+ });
+
+ const clock = sinon.useFakeTimers();
+ const promise = localScan(1);
+ await clock.tickAsync(1100);
+ const result = await promise;
+ clock.restore();
+
+ expect(result).to.deep.equal({
+ devices: {},
+ portErrors: {},
+ });
+ });
+
+ it('should complete local scan when listening address is available', async () => {
+ const sockets = [];
+ const dgramStub = {
+ createSocket: () => {
+ const handlers = {};
+ const socket = {
+ on: (event, cb) => {
+ handlers[event] = cb;
+ },
+ bind: () => {
+ if (handlers.listening) {
+ handlers.listening();
+ }
+ },
+ close: () => {},
+ address: () => ({ address: '0.0.0.0', port: 6666 }),
+ handlers,
+ };
+ sockets.push(socket);
+ return socket;
+ },
+ };
+
+ class MessageParserStub {
+ parse() {
+ return [{ payload: null }];
+ }
+ }
+
+ const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ dgram: dgramStub,
+ '@demirdeniz/tuyapi-newgen/lib/message-parser': { MessageParser: MessageParserStub },
+ '@demirdeniz/tuyapi-newgen/lib/config': { UDP_KEY: 'key' },
+ });
+
+ const clock = sinon.useFakeTimers();
+ const promise = localScan(1);
+ await clock.tickAsync(1100);
+ const result = await promise;
+ clock.restore();
+
+ expect(result).to.deep.equal({
+ devices: {},
+ portErrors: {},
+ });
+ });
+
+ it('should support object input and ignore parser results that return null', async () => {
+ const sockets = [];
+ const dgramStub = {
+ createSocket: () => {
+ const handlers = {};
+ const socket = {
+ on: (event, cb) => {
+ handlers[event] = cb;
+ },
+ bind: (options, cb) => {
+ if (typeof options === 'function') {
+ options();
+ return;
+ }
+ if (cb) {
+ cb();
+ }
+ },
+ close: () => {},
+ handlers,
+ };
+ sockets.push(socket);
+ return socket;
+ },
+ };
+
+ class MessageParserStub {
+ parse() {
+ return null;
+ }
+ }
+
+ const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ dgram: dgramStub,
+ '@demirdeniz/tuyapi-newgen/lib/message-parser': { MessageParser: MessageParserStub },
+ '@demirdeniz/tuyapi-newgen/lib/config': { UDP_KEY: 'key' },
+ });
+
+ const clock = sinon.useFakeTimers();
+ const promise = localScan({ timeoutSeconds: 1 });
+
+ sockets.forEach((socket) => {
+ if (socket.handlers.message) {
+ socket.handlers.message(null);
+ }
+ });
+
+ await clock.tickAsync(1100);
+ const result = await promise;
+ clock.restore();
+
+ expect(result).to.deep.equal({
+ devices: {},
+ portErrors: {},
+ });
+ });
+
+ it('should resolve device id from devId and fallback to rinfo address', async () => {
+ const sockets = [];
+ const dgramStub = {
+ createSocket: () => {
+ const handlers = {};
+ const socket = {
+ on: (event, cb) => {
+ handlers[event] = cb;
+ },
+ bind: (options, cb) => {
+ if (typeof options === 'function') {
+ options();
+ return;
+ }
+ if (cb) {
+ cb();
+ }
+ },
+ close: () => {},
+ handlers,
+ };
+ sockets.push(socket);
+ return socket;
+ },
+ };
+
+ class MessageParserStub {
+ parse() {
+ return [
+ {
+ payload: {
+ devId: 'device-dev-id',
+ version: '3.5',
+ productKey: 'product-key',
+ },
+ },
+ ];
+ }
+ }
+
+ const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ dgram: dgramStub,
+ '@demirdeniz/tuyapi-newgen/lib/message-parser': { MessageParser: MessageParserStub },
+ '@demirdeniz/tuyapi-newgen/lib/config': { UDP_KEY: 'key' },
+ });
+
+ const clock = sinon.useFakeTimers();
+ const promise = localScan(1);
+
+ sockets.forEach((socket) => {
+ if (socket.handlers.message) {
+ socket.handlers.message(Buffer.from('test'), {
+ address: '9.9.9.9',
+ port: 6666,
+ source: 'lan',
+ });
+ }
+ });
+
+ await clock.tickAsync(1100);
+ const result = await promise;
+ clock.restore();
+
+ expect(result).to.deep.equal({
+ devices: {
+ 'device-dev-id': {
+ ip: '9.9.9.9',
+ version: '3.5',
+ productKey: 'product-key',
+ },
+ },
+ portErrors: {},
+ });
+ });
+
+ it('should resolve device id from payload id when gwId and devId are missing', async () => {
+ const sockets = [];
+ const dgramStub = {
+ createSocket: () => {
+ const handlers = {};
+ const socket = {
+ on: (event, cb) => {
+ handlers[event] = cb;
+ },
+ bind: (options, cb) => {
+ if (typeof options === 'function') {
+ options();
+ return;
+ }
+ if (cb) {
+ cb();
+ }
+ },
+ close: () => {},
+ handlers,
+ };
+ sockets.push(socket);
+ return socket;
+ },
+ };
+
+ class MessageParserStub {
+ parse() {
+ return [
+ {
+ payload: {
+ id: 'device-payload-id',
+ ip: '8.8.8.8',
+ version: '3.3',
+ },
+ },
+ ];
+ }
+ }
+
+ const { localScan } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ dgram: dgramStub,
+ '@demirdeniz/tuyapi-newgen/lib/message-parser': { MessageParser: MessageParserStub },
+ '@demirdeniz/tuyapi-newgen/lib/config': { UDP_KEY: 'key' },
+ });
+
+ const clock = sinon.useFakeTimers();
+ const promise = localScan(1);
+
+ sockets.forEach((socket) => {
+ if (socket.handlers.message) {
+ socket.handlers.message(Buffer.from('test'), {
+ address: '7.7.7.7',
+ port: 6667,
+ });
+ }
+ });
+
+ await clock.tickAsync(1100);
+ const result = await promise;
+ clock.restore();
+
+ expect(result).to.deep.equal({
+ devices: {
+ 'device-payload-id': {
+ ip: '8.8.8.8',
+ version: '3.3',
+ productKey: undefined,
+ },
+ },
+ portErrors: {},
+ });
+ });
+});
+
+describe('TuyaHandler.buildLocalScanResponse', () => {
+ it('should return devices and port errors when discovered devices exist', () => {
+ const { buildLocalScanResponse } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {});
+ const tuyaManager = {
+ discoveredDevices: [{ external_id: 'tuya:device1', params: [] }],
+ };
+ const response = buildLocalScanResponse(tuyaManager, {
+ devices: { device1: { ip: '1.1.1.1', version: '3.3', productKey: 'pkey' } },
+ portErrors: { 6666: 'boom' },
+ });
+
+ expect(response.port_errors).to.deep.equal({ 6666: 'boom' });
+ expect(response.local_devices).to.deep.equal({ device1: { ip: '1.1.1.1', version: '3.3', productKey: 'pkey' } });
+ expect(response.devices).to.be.an('array');
+ expect(response.devices[0].ip).to.equal('1.1.1.1');
+ const ipParam = response.devices[0].params.find((param) => param.name === 'IP_ADDRESS');
+ expect(ipParam.value).to.equal('1.1.1.1');
+ expect(response.devices[0].tuya_report.local.scan).to.deep.equal({
+ source: 'udp',
+ response: { ip: '1.1.1.1', version: '3.3', productKey: 'pkey' },
+ });
+ });
+
+ it('should merge devices when gladys stateManager exists', () => {
+ const { buildLocalScanResponse } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {});
+ const tuyaManager = {
+ discoveredDevices: [{ external_id: 'tuya:device1', params: [] }],
+ gladys: {
+ stateManager: {
+ get: sinon.stub().returns({
+ external_id: 'tuya:device1',
+ params: [{ name: 'LOCAL_OVERRIDE', value: true }],
+ features: [],
+ }),
+ },
+ },
+ };
+ const response = buildLocalScanResponse(tuyaManager, {
+ devices: { device1: { ip: '1.1.1.1', version: '3.3' } },
+ portErrors: {},
+ });
+
+ expect(response.devices[0]).to.have.property('updatable');
+ expect(response.devices[0].local_override).to.equal(true);
+ });
+
+ it('should return only local devices when no discovered devices array', () => {
+ const { buildLocalScanResponse } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {});
+ const tuyaManager = { discoveredDevices: null };
+ const response = buildLocalScanResponse(tuyaManager, null);
+
+ expect(response).to.deep.equal({ local_devices: {}, port_errors: {} });
+ });
+
+ it('should append local-only devices when cloud discovered list is empty', () => {
+ const { buildLocalScanResponse } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ './device/tuya.convertDevice': {
+ convertDevice: sinon.stub().callsFake((device) => ({
+ external_id: `tuya:${device.id}`,
+ params: [{ name: 'IP_ADDRESS', value: device.ip }],
+ tuya_report: mergeTuyaReport(null, device.tuya_report),
+ })),
+ },
+ });
+ const tuyaManager = {
+ discoveredDevices: [],
+ gladys: {
+ stateManager: {
+ get: sinon.stub().returns(null),
+ },
+ },
+ };
+
+ const response = buildLocalScanResponse(tuyaManager, {
+ devices: { device2: { ip: '2.2.2.2', version: '3.3', productKey: 'pkey' } },
+ portErrors: {},
+ });
+
+ expect(response.devices).to.have.length(1);
+ expect(response.devices[0].external_id).to.equal('tuya:device2');
+ expect(response.local_devices).to.deep.equal({ device2: { ip: '2.2.2.2', version: '3.3', productKey: 'pkey' } });
+ expect(response.devices[0].tuya_report).to.deep.equal({
+ schema_version: 2,
+ cloud: {
+ assembled: {
+ specifications: null,
+ properties: null,
+ thing_model: null,
+ },
+ raw: {
+ device_list_entry: null,
+ device_specification: null,
+ device_details: null,
+ thing_shadow_properties: null,
+ thing_model: null,
+ },
+ },
+ local: {
+ scan: {
+ source: 'udp',
+ response: { ip: '2.2.2.2', version: '3.3', productKey: 'pkey' },
+ },
+ },
+ });
+ });
+
+ it('should persist local-only devices when discoveredDevices is not an array', () => {
+ const { buildLocalScanResponse } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ './device/tuya.convertDevice': {
+ convertDevice: sinon.stub().callsFake((device) => ({
+ external_id: `tuya:${device.id}`,
+ params: [{ name: 'IP_ADDRESS', value: device.ip }],
+ tuya_report: mergeTuyaReport(null, device.tuya_report),
+ })),
+ },
+ });
+ const tuyaManager = {
+ discoveredDevices: null,
+ gladys: {
+ stateManager: {
+ get: sinon.stub().returns(null),
+ },
+ },
+ };
+
+ const response = buildLocalScanResponse(tuyaManager, {
+ devices: { device2: { ip: '2.2.2.2', version: '3.3', productKey: 'pkey' } },
+ portErrors: {},
+ });
+
+ expect(response.devices).to.have.length(1);
+ expect(tuyaManager.discoveredDevices).to.have.length(1);
+ expect(tuyaManager.discoveredDevices[0].external_id).to.equal('tuya:device2');
+ expect(tuyaManager.discoveredDevices[0].tuya_report.local.scan).to.deep.equal({
+ source: 'udp',
+ response: { ip: '2.2.2.2', version: '3.3', productKey: 'pkey' },
+ });
+ });
+
+ it('should keep local device name when creating a local-only discovered device', () => {
+ const convertDevice = sinon.stub().callsFake((device) => ({
+ external_id: `tuya:${device.id}`,
+ name: device.name,
+ params: [{ name: 'IP_ADDRESS', value: device.ip }],
+ tuya_report: mergeTuyaReport(null, device.tuya_report),
+ }));
+ const { buildLocalScanResponse } = proxyquire('../../../../services/tuya/lib/tuya.localScan', {
+ './device/tuya.convertDevice': {
+ convertDevice,
+ },
+ });
+ const tuyaManager = {
+ discoveredDevices: [],
+ gladys: {
+ stateManager: {
+ get: sinon.stub().returns(null),
+ },
+ },
+ };
+
+ const response = buildLocalScanResponse(tuyaManager, {
+ devices: { device3: { name: 'Kitchen Plug', ip: '3.3.3.3', version: '3.5' } },
+ portErrors: {},
+ });
+
+ expect(convertDevice.calledOnce).to.equal(true);
+ expect(convertDevice.firstCall.args[0].name).to.equal('Kitchen Plug');
+ expect(response.devices[0].name).to.equal('Kitchen Plug');
+ });
+});
diff --git a/server/test/services/tuya/lib/tuya.manualDisconnect.test.js b/server/test/services/tuya/lib/tuya.manualDisconnect.test.js
new file mode 100644
index 0000000000..a0b0a143c8
--- /dev/null
+++ b/server/test/services/tuya/lib/tuya.manualDisconnect.test.js
@@ -0,0 +1,35 @@
+const sinon = require('sinon');
+
+const { assert } = sinon;
+
+const TuyaHandler = require('../../../../services/tuya/lib/index');
+const { GLADYS_VARIABLES } = require('../../../../services/tuya/lib/utils/tuya.constants');
+
+const gladys = {
+ variable: {
+ setValue: sinon.fake.resolves(null),
+ },
+};
+const serviceId = 'ffa13430-df93-488a-9733-5c540e9558e0';
+
+describe('TuyaHandler.manualDisconnect', () => {
+ const tuyaHandler = new TuyaHandler(gladys, serviceId);
+
+ beforeEach(() => {
+ sinon.reset();
+ });
+
+ afterEach(() => {
+ sinon.reset();
+ });
+
+ it('should persist manual disconnect and call disconnect', async () => {
+ const disconnectStub = sinon.stub().resolves(null);
+ tuyaHandler.disconnect = disconnectStub;
+
+ await tuyaHandler.manualDisconnect();
+
+ assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.MANUAL_DISCONNECT, 'true', serviceId);
+ assert.calledWith(disconnectStub, { manual: true });
+ });
+});
diff --git a/server/test/services/tuya/lib/tuya.poll.fixtures.test.js b/server/test/services/tuya/lib/tuya.poll.fixtures.test.js
new file mode 100644
index 0000000000..1af4d7df86
--- /dev/null
+++ b/server/test/services/tuya/lib/tuya.poll.fixtures.test.js
@@ -0,0 +1,127 @@
+const sinon = require('sinon');
+const { expect } = require('chai');
+const proxyquire = require('proxyquire').noCallThru();
+
+const { EVENTS } = require('../../../../utils/constants');
+const { loadFixtureCases, normalizeEvents, sortByKey } = require('../fixtures/fixtureHelper');
+const { poll: pollCloud } = require('../../../../services/tuya/lib/tuya.poll');
+const { getLocalDpsFromCode } = require('../../../../services/tuya/lib/device/tuya.localMapping');
+
+const createGladysContext = () => ({
+ event: {
+ emit: sinon.stub(),
+ },
+});
+
+const cloneDeep = (value) => JSON.parse(JSON.stringify(value));
+
+const getFeatureCode = (feature) => {
+ if (!feature || !feature.external_id) {
+ return null;
+ }
+ return String(feature.external_id)
+ .split(':')
+ .pop();
+};
+
+const forceCloudMode = (device) => ({
+ ...device,
+ params: (Array.isArray(device.params) ? device.params : []).map((param) =>
+ param.name === 'LOCAL_OVERRIDE' ? { ...param, value: false } : param,
+ ),
+});
+
+describe('TuyaHandler.poll fixtures', () => {
+ const cloudCases = loadFixtureCases('pollCloud');
+ const localCases = loadFixtureCases('pollLocal');
+
+ cloudCases.forEach((fixtureCase) => {
+ it(`should poll cloud values for ${fixtureCase.manifest.name} from fixture`, async () => {
+ const { device, response, expectedEvents } = fixtureCase.manifest.pollCloud;
+ const connector = {
+ request: sinon.stub().resolves(fixtureCase.load(response)),
+ };
+ const gladys = createGladysContext();
+ const cloudDevice = forceCloudMode(fixtureCase.load(device));
+
+ await pollCloud.call(
+ {
+ connector,
+ gladys,
+ },
+ cloudDevice,
+ );
+ const emittedEvents = gladys.event.emit.getCalls().filter((call) => call.args[0] === EVENTS.DEVICE.NEW_STATE);
+ expect(normalizeEvents(emittedEvents)).to.deep.equal(sortByKey(fixtureCase.load(expectedEvents)));
+ });
+ });
+
+ localCases.forEach((fixtureCase) => {
+ it(`should poll local values for ${fixtureCase.manifest.name} from fixture`, async () => {
+ const { device, dps, expectedEvents, expectedCloudRequests = 0 } = fixtureCase.manifest.pollLocal;
+ const localPoll = sinon.stub().resolves({ dps: fixtureCase.load(dps) });
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ './tuya.localPoll': { localPoll },
+ });
+ const connector = {
+ request: sinon.stub().resolves({ result: [] }),
+ };
+ const gladys = createGladysContext();
+
+ await poll.call(
+ {
+ connector,
+ gladys,
+ },
+ fixtureCase.load(device),
+ );
+ const emittedEvents = gladys.event.emit.getCalls().filter((call) => call.args[0] === EVENTS.DEVICE.NEW_STATE);
+ expect(normalizeEvents(emittedEvents)).to.deep.equal(sortByKey(fixtureCase.load(expectedEvents)));
+ expect(connector.request.callCount).to.equal(expectedCloudRequests);
+ });
+
+ it(`should not emit a feature state when its local dps is missing for ${fixtureCase.manifest.name}`, async () => {
+ const { device, dps } = fixtureCase.manifest.pollLocal;
+ const currentDevice = fixtureCase.load(device);
+ const currentDps = fixtureCase.load(dps);
+ const removableFeature = (Array.isArray(currentDevice.features) ? currentDevice.features : [])
+ .map((feature) => {
+ const code = getFeatureCode(feature);
+ const dpsKey = getLocalDpsFromCode(code, currentDevice);
+ return {
+ feature,
+ code,
+ dpsKey,
+ };
+ })
+ .find(({ dpsKey }) => dpsKey !== null && Object.prototype.hasOwnProperty.call(currentDps, String(dpsKey)));
+
+ expect(removableFeature, 'fixture should expose at least one locally mapped dps').to.not.equal(undefined);
+
+ const degradedDps = cloneDeep(currentDps);
+ delete degradedDps[String(removableFeature.dpsKey)];
+
+ const localPoll = sinon.stub().resolves({ dps: degradedDps });
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ './tuya.localPoll': { localPoll },
+ });
+ const connector = {
+ request: sinon.stub().resolves({ result: [] }),
+ };
+ const gladys = createGladysContext();
+
+ await poll.call(
+ {
+ connector,
+ gladys,
+ },
+ currentDevice,
+ );
+
+ const emittedEvents = gladys.event.emit.getCalls().filter((call) => call.args[0] === EVENTS.DEVICE.NEW_STATE);
+ const emittedFeatureIds = emittedEvents.map((call) => call.args[1].device_feature_external_id);
+
+ expect(emittedFeatureIds).to.not.include(removableFeature.feature.external_id);
+ });
+ });
+});
diff --git a/server/test/services/tuya/lib/tuya.poll.test.js b/server/test/services/tuya/lib/tuya.poll.test.js
index a04fe1883a..1c2da12812 100644
--- a/server/test/services/tuya/lib/tuya.poll.test.js
+++ b/server/test/services/tuya/lib/tuya.poll.test.js
@@ -11,8 +11,8 @@ const connect = proxyquire('../../../../services/tuya/lib/tuya.connect', {
const TuyaHandler = proxyquire('../../../../services/tuya/lib/index', {
'./tuya.connect.js': connect,
});
-const { API } = require('../../../../services/tuya/lib/utils/tuya.constants');
const { EVENTS } = require('../../../../utils/constants');
+const { API } = require('../../../../services/tuya/lib/utils/tuya.constants');
const { BadParameters } = require('../../../../utils/coreErrors');
@@ -35,7 +35,12 @@ describe('TuyaHandler.poll', () => {
request: sinon
.stub()
.onFirstCall()
- .resolves({ result: [{ code: 'code', value: true }], total: 1, has_more: true, last_row_key: 'next' }),
+ .resolves({
+ result: [{ code: 'switch_1', value: true }],
+ total: 1,
+ has_more: true,
+ last_row_key: 'next',
+ }),
};
});
@@ -55,6 +60,7 @@ describe('TuyaHandler.poll', () => {
},
],
});
+ expect.fail('Expected BadParameters to be thrown');
} catch (error) {
expect(error).to.be.an.instanceof(BadParameters);
expect(error.message).to.equal('Tuya device external_id is invalid: "test:device" should starts with "tuya:"');
@@ -73,18 +79,44 @@ describe('TuyaHandler.poll', () => {
},
],
});
+ expect.fail('Expected BadParameters to be thrown');
} catch (error) {
expect(error).to.be.an.instanceof(BadParameters);
expect(error.message).to.equal('Tuya device external_id is invalid: "tuya" have no network indicator');
}
});
- it('change state of device feature', async () => {
+ it('should return without throwing when final cloud poll fails', async () => {
+ tuyaHandler.connector.request = sinon.stub().rejects(new Error('cloud failed'));
+ const logger = {
+ debug: sinon.stub(),
+ warn: sinon.stub(),
+ };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ '../../../utils/logger': logger,
+ });
+
+ await poll.call(tuyaHandler, {
+ external_id: 'tuya:device',
+ features: [
+ {
+ external_id: 'tuya:device:switch_1',
+ category: 'light',
+ type: 'binary',
+ },
+ ],
+ });
+
+ assert.callCount(logger.warn, 1);
+ expect(logger.warn.firstCall.args[0]).to.include('cloud poll failed');
+ assert.callCount(gladys.event.emit, 0);
+ });
+ it('should skip cloud feature when code is missing from payload', async () => {
await tuyaHandler.poll({
external_id: 'tuya:device',
features: [
{
- external_id: 'tuya:feature',
+ external_id: 'tuya:device:missing_code',
category: 'light',
type: 'binary',
},
@@ -96,11 +128,674 @@ describe('TuyaHandler.poll', () => {
method: 'GET',
path: `${API.VERSION_1_0}/devices/device/status`,
});
+ assert.callCount(gladys.event.emit, 0);
+ });
+
+ it('should continue cloud poll when one reader throws', async () => {
+ tuyaHandler.connector.request = sinon.stub().resolves({
+ result: [
+ { code: 'colour_data', value: '{' },
+ { code: 'switch_1', value: true },
+ ],
+ });
+ const logger = {
+ debug: sinon.stub(),
+ warn: sinon.stub(),
+ };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ '../../../utils/logger': logger,
+ });
+ await poll.call(tuyaHandler, {
+ external_id: 'tuya:device',
+ features: [
+ {
+ external_id: 'tuya:device:colour_data',
+ category: 'light',
+ type: 'color',
+ },
+ {
+ external_id: 'tuya:device:switch_1',
+ category: 'light',
+ type: 'binary',
+ },
+ ],
+ });
+
+ assert.callCount(logger.warn, 1);
+ expect(logger.warn.firstCall.args[0]).to.include('reader failed');
+ assert.callCount(gladys.event.emit, 1);
+ assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, {
+ device_feature_external_id: 'tuya:device:switch_1',
+ state: 1,
+ });
+ });
+
+ it('should read cloud values from thing shadow when strategy is shadow', async () => {
+ tuyaHandler.connector.request = sinon.stub().resolves({
+ result: {
+ properties: [{ code: 'power_a', value: 706 }],
+ },
+ });
+
+ await tuyaHandler.poll({
+ external_id: 'tuya:device',
+ params: [{ name: 'CLOUD_READ_STRATEGY', value: 'shadow' }],
+ features: [
+ {
+ external_id: 'tuya:device:power_a',
+ category: 'energy-sensor',
+ type: 'power',
+ scale: 1,
+ },
+ ],
+ });
+
+ assert.callCount(tuyaHandler.connector.request, 1);
+ assert.calledWith(tuyaHandler.connector.request, {
+ method: 'GET',
+ path: `${API.VERSION_2_0}/thing/device/shadow/properties`,
+ });
assert.callCount(gladys.event.emit, 1);
assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, {
- device_feature_external_id: 'tuya:feature',
- state: 0,
+ device_feature_external_id: 'tuya:device:power_a',
+ state: 70.6,
+ });
+ });
+
+ it('should return without cloud request when feature list is empty', async () => {
+ await tuyaHandler.poll({
+ external_id: 'tuya:device',
+ features: [],
+ });
+
+ assert.callCount(tuyaHandler.connector.request, 0);
+ assert.callCount(gladys.event.emit, 0);
+ });
+});
+
+describe('TuyaHandler.poll with local mapping', () => {
+ it('should use local dps and skip cloud when all features are mapped', async () => {
+ const localPoll = sinon.stub().resolves({ dps: { 1: true } });
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ './tuya.localPoll': { localPoll },
+ });
+
+ const context = {
+ connector: {
+ request: sinon.stub(),
+ },
+ gladys: {
+ event: {
+ emit: sinon.stub(),
+ },
+ },
+ };
+
+ await poll.call(context, {
+ external_id: 'tuya:device',
+ params: [
+ { name: 'IP_ADDRESS', value: '1.1.1.1' },
+ { name: 'LOCAL_KEY', value: 'key' },
+ { name: 'PROTOCOL_VERSION', value: '3.3' },
+ { name: 'LOCAL_OVERRIDE', value: true },
+ ],
+ features: [
+ {
+ external_id: 'tuya:device:switch_1',
+ category: 'switch',
+ type: 'binary',
+ },
+ ],
+ });
+
+ expect(localPoll.calledOnce).to.equal(true);
+ expect(context.connector.request.called).to.equal(false);
+ expect(context.gladys.event.emit.calledOnce).to.equal(true);
+ });
+
+ it('should fallback to cloud for unmapped local feature', async () => {
+ const localPoll = sinon.stub().resolves({ dps: { 1: true } });
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ './tuya.localPoll': { localPoll },
+ });
+
+ const request = sinon.stub().resolves({
+ result: [{ code: 'countdown', value: true }],
+ });
+ const emit = sinon.stub();
+
+ await poll.call(
+ {
+ connector: { request },
+ gladys: {
+ event: { emit },
+ },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [
+ { name: 'IP_ADDRESS', value: '1.1.1.1' },
+ { name: 'LOCAL_KEY', value: 'key' },
+ { name: 'PROTOCOL_VERSION', value: '3.3' },
+ { name: 'LOCAL_OVERRIDE', value: true },
+ ],
+ features: [
+ {
+ external_id: 'tuya:device:countdown',
+ category: 'switch',
+ type: 'binary',
+ },
+ ],
+ },
+ );
+
+ expect(localPoll.calledOnce).to.equal(true);
+ expect(request.calledOnce).to.equal(true);
+ expect(emit.calledOnce).to.equal(true);
+ });
+
+ it('should use state manager cached value to detect local OFF changes', async () => {
+ const localPoll = sinon
+ .stub()
+ .onFirstCall()
+ .resolves({ dps: { 1: true } })
+ .onSecondCall()
+ .resolves({ dps: { 1: false } });
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ './tuya.localPoll': { localPoll },
+ });
+
+ let cachedValue = 0;
+ const emit = sinon.stub().callsFake((eventType, payload) => {
+ if (eventType === EVENTS.DEVICE.NEW_STATE) {
+ cachedValue = payload.state;
+ }
+ });
+
+ const context = {
+ connector: {
+ request: sinon.stub(),
+ },
+ gladys: {
+ event: {
+ emit,
+ },
+ stateManager: {
+ get: sinon.stub().callsFake((entity, selector) => {
+ if (entity === 'deviceFeature' && selector === 'tuya-device-switch-1') {
+ return { last_value: cachedValue };
+ }
+ return null;
+ }),
+ },
+ },
+ };
+
+ const device = {
+ external_id: 'tuya:device',
+ params: [
+ { name: 'IP_ADDRESS', value: '1.1.1.1' },
+ { name: 'LOCAL_KEY', value: 'key' },
+ { name: 'PROTOCOL_VERSION', value: '3.3' },
+ { name: 'LOCAL_OVERRIDE', value: true },
+ ],
+ features: [
+ {
+ external_id: 'tuya:device:switch_1',
+ selector: 'tuya-device-switch-1',
+ category: 'switch',
+ type: 'binary',
+ last_value: 0,
+ },
+ ],
+ };
+
+ await poll.call(context, device);
+ await poll.call(context, device);
+
+ expect(localPoll.calledTwice).to.equal(true);
+ expect(emit.calledTwice).to.equal(true);
+ expect(emit.firstCall.args[1].state).to.equal(1);
+ expect(emit.secondCall.args[1].state).to.equal(0);
+ });
+
+ it('should emit same cloud value only after heartbeat interval', async () => {
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {});
+
+ const request = sinon.stub().resolves({
+ result: [{ code: 'switch_1', value: false }],
});
+
+ const clock = sinon.useFakeTimers(new Date('2026-02-27T08:00:00.000Z').getTime());
+ try {
+ const cachedState = {
+ last_value: 0,
+ last_value_changed: new Date(clock.now).toISOString(),
+ };
+ const emit = sinon.stub().callsFake((eventType, payload) => {
+ if (eventType === EVENTS.DEVICE.NEW_STATE) {
+ cachedState.last_value = payload.state;
+ cachedState.last_value_changed = new Date(clock.now).toISOString();
+ }
+ });
+
+ const context = {
+ connector: { request },
+ gladys: {
+ event: { emit },
+ stateManager: {
+ get: sinon.stub().callsFake((entity, selector) => {
+ if (entity === 'deviceFeature' && selector === 'tuya-device-switch-1') {
+ return cachedState;
+ }
+ return null;
+ }),
+ },
+ },
+ };
+
+ const device = {
+ external_id: 'tuya:device',
+ params: [{ name: 'LOCAL_OVERRIDE', value: false }],
+ features: [
+ {
+ external_id: 'tuya:device:switch_1',
+ selector: 'tuya-device-switch-1',
+ category: 'switch',
+ type: 'binary',
+ last_value: 0,
+ last_value_changed: new Date(clock.now).toISOString(),
+ },
+ ],
+ };
+
+ await poll.call(context, device);
+ expect(emit.called).to.equal(false);
+
+ clock.tick(2 * 60 * 1000);
+ await poll.call(context, device);
+ expect(emit.called).to.equal(false);
+
+ clock.tick(60 * 1000 + 1);
+ await poll.call(context, device);
+ expect(emit.calledOnce).to.equal(true);
+ expect(emit.firstCall.args[1].state).to.equal(0);
+ } finally {
+ clock.restore();
+ }
+ });
+});
+
+describe('TuyaHandler.poll additional branch coverage', () => {
+ it('should not throw when features payload is not an array', async () => {
+ const request = sinon.stub().resolves({ result: [{ code: 'switch_1', value: true }] });
+ const emit = sinon.stub();
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ '../../../utils/logger': logger,
+ });
+
+ await poll.call(
+ {
+ connector: { request },
+ gladys: { event: { emit } },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [{ name: 'LOCAL_OVERRIDE', value: false }],
+ features: null,
+ },
+ );
+
+ expect(request.called).to.equal(false);
+ expect(emit.called).to.equal(false);
+ });
+
+ it('should warn and return when cloud connector is unavailable', async () => {
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ '../../../utils/logger': logger,
+ });
+
+ await poll.call(
+ {
+ connector: null,
+ gladys: { event: { emit: sinon.stub() } },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [{ name: 'LOCAL_OVERRIDE', value: false }],
+ features: [{ external_id: 'tuya:device:switch_1', category: 'switch', type: 'binary' }],
+ },
+ );
+
+ expect(logger.warn.calledOnce).to.equal(true);
+ });
+
+ it('should ignore malformed cloud status entries', async () => {
+ const request = sinon
+ .stub()
+ .resolves({ result: [null, 'bad', { value: true }, { code: 'switch_1', value: true }] });
+ const emit = sinon.stub();
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ '../../../utils/logger': logger,
+ });
+
+ await poll.call(
+ {
+ connector: { request },
+ gladys: { event: { emit } },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [{ name: 'LOCAL_OVERRIDE', value: false }],
+ features: [{ external_id: 'tuya:device:switch_1', category: 'switch', type: 'binary' }],
+ },
+ );
+
+ expect(request.calledOnce).to.equal(true);
+ expect(emit.calledOnce).to.equal(true);
+ expect(emit.firstCall.args[1].state).to.equal(1);
+ });
+
+ it('should skip cloud features when code or reader is missing', async () => {
+ const request = sinon.stub().resolves({ result: [{ code: 'switch_1', value: true }] });
+ const emit = sinon.stub();
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ '../../../utils/logger': logger,
+ });
+
+ await poll.call(
+ {
+ connector: { request },
+ gladys: { event: { emit } },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [{ name: 'LOCAL_OVERRIDE', value: false }],
+ features: [
+ { category: 'switch', type: 'binary' },
+ { external_id: 'invalid', category: 'switch', type: 'binary' },
+ { external_id: 'tuya:device:switch_1' },
+ { external_id: 'tuya:device:switch_1', category: 'unknown', type: 'binary' },
+ ],
+ },
+ );
+
+ expect(request.calledOnce).to.equal(true);
+ expect(emit.called).to.equal(false);
+ });
+
+ it('should warn when local mode is enabled with incomplete config', async () => {
+ const request = sinon.stub().resolves({ result: [{ code: 'switch_1', value: false }] });
+ const emit = sinon.stub();
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ '../../../utils/logger': logger,
+ });
+
+ await poll.call(
+ {
+ connector: { request },
+ gladys: { event: { emit } },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [
+ { name: 'LOCAL_OVERRIDE', value: true },
+ { name: 'IP_ADDRESS', value: '1.1.1.1' },
+ ],
+ features: [{ external_id: 'tuya:device:switch_1', category: 'switch', type: 'binary' }],
+ },
+ );
+
+ expect(logger.warn.calledOnce).to.equal(true);
+ expect(request.calledOnce).to.equal(true);
+ });
+
+ it('should warn and fallback to cloud when local payload has no dps object', async () => {
+ const localPoll = sinon.stub().resolves({});
+ const request = sinon.stub().resolves({ result: [{ code: 'switch_1', value: true }] });
+ const emit = sinon.stub();
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ './tuya.localPoll': { localPoll },
+ '../../../utils/logger': logger,
+ });
+
+ await poll.call(
+ {
+ connector: { request },
+ gladys: { event: { emit } },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [
+ { name: 'IP_ADDRESS', value: '1.1.1.1' },
+ { name: 'LOCAL_KEY', value: 'key' },
+ { name: 'PROTOCOL_VERSION', value: '3.3' },
+ { name: 'LOCAL_OVERRIDE', value: true },
+ ],
+ features: [{ external_id: 'tuya:device:switch_1', category: 'switch', type: 'binary' }],
+ },
+ );
+
+ expect(localPoll.calledOnce).to.equal(true);
+ expect(request.calledOnce).to.equal(true);
+ expect(logger.warn.calledOnce).to.equal(true);
+ expect(logger.warn.firstCall.args[0]).to.include('invalid DPS payload');
+ });
+
+ it('should fallback to cloud when local dps value is undefined', async () => {
+ const localPoll = sinon.stub().resolves({ dps: { 1: undefined } });
+ const request = sinon.stub().resolves({ result: [{ code: 'switch_1', value: true }] });
+ const emit = sinon.stub();
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ './tuya.localPoll': { localPoll },
+ '../../../utils/logger': logger,
+ });
+
+ await poll.call(
+ {
+ connector: { request },
+ gladys: { event: { emit } },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [
+ { name: 'IP_ADDRESS', value: '1.1.1.1' },
+ { name: 'LOCAL_KEY', value: 'key' },
+ { name: 'PROTOCOL_VERSION', value: '3.3' },
+ { name: 'LOCAL_OVERRIDE', value: true },
+ ],
+ features: [{ external_id: 'tuya:device:switch_1', category: 'switch', type: 'binary' }],
+ },
+ );
+
+ expect(localPoll.calledOnce).to.equal(true);
+ expect(request.calledOnce).to.equal(true);
+ });
+
+ it('should warn and fallback to cloud when local reader throws', async () => {
+ const localPoll = sinon.stub().resolves({ dps: { 1: true } });
+ const request = sinon.stub().resolves({ result: [{ code: 'switch_1', value: false }] });
+ const emit = sinon.stub();
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ './tuya.localPoll': { localPoll },
+ '../../../utils/logger': logger,
+ './device/tuya.deviceMapping': {
+ readValues: {
+ switch: {
+ binary: (value) => {
+ if (value === true) {
+ throw new Error('bad local value');
+ }
+ return 0;
+ },
+ },
+ },
+ },
+ });
+
+ await poll.call(
+ {
+ connector: { request },
+ gladys: { event: { emit } },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [
+ { name: 'IP_ADDRESS', value: '1.1.1.1' },
+ { name: 'LOCAL_KEY', value: 'key' },
+ { name: 'PROTOCOL_VERSION', value: '3.3' },
+ { name: 'LOCAL_OVERRIDE', value: true },
+ ],
+ features: [{ external_id: 'tuya:device:switch_1', category: 'switch', type: 'binary' }],
+ },
+ );
+
+ expect(localPoll.calledOnce).to.equal(true);
+ expect(request.calledOnce).to.equal(true);
+ expect(logger.warn.calledOnce).to.equal(true);
+ expect(logger.warn.firstCall.args[0]).to.include('local reader failed');
+ expect(emit.calledOnce).to.equal(true);
+ expect(emit.firstCall.args[1].state).to.equal(0);
+ });
+
+ it('should warn when cloud fallback fails after local success', async () => {
+ const localPoll = sinon.stub().resolves({ dps: { 1: true } });
+ const request = sinon.stub().rejects(new Error('cloud down'));
+ const emit = sinon.stub();
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ './tuya.localPoll': { localPoll },
+ '../../../utils/logger': logger,
+ });
+
+ await poll.call(
+ {
+ connector: { request },
+ gladys: { event: { emit } },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [
+ { name: 'IP_ADDRESS', value: '1.1.1.1' },
+ { name: 'LOCAL_KEY', value: 'key' },
+ { name: 'PROTOCOL_VERSION', value: '3.3' },
+ { name: 'LOCAL_OVERRIDE', value: true },
+ ],
+ features: [
+ { external_id: 'tuya:device:switch_1', category: 'switch', type: 'binary' },
+ { external_id: 'tuya:device:countdown', category: 'switch', type: 'binary' },
+ ],
+ },
+ );
+
+ expect(localPoll.calledOnce).to.equal(true);
+ expect(logger.warn.calledOnce).to.equal(true);
+ expect(emit.calledOnce).to.equal(true);
+ });
+
+ it('should warn and fallback to cloud when local poll throws', async () => {
+ const localPoll = sinon.stub().rejects(new Error('local down'));
+ const request = sinon.stub().resolves({ result: [{ code: 'switch_1', value: true }] });
+ const emit = sinon.stub();
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ './tuya.localPoll': { localPoll },
+ '../../../utils/logger': logger,
+ });
+
+ await poll.call(
+ {
+ connector: { request },
+ gladys: { event: { emit } },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [
+ { name: 'IP_ADDRESS', value: '1.1.1.1' },
+ { name: 'LOCAL_KEY', value: 'key' },
+ { name: 'PROTOCOL_VERSION', value: '3.3' },
+ { name: 'LOCAL_OVERRIDE', value: true },
+ ],
+ features: [{ external_id: 'tuya:device:switch_1', category: 'switch', type: 'binary' }],
+ },
+ );
+
+ expect(localPoll.calledOnce).to.equal(true);
+ expect(request.calledOnce).to.equal(true);
+ expect(logger.warn.calledOnce).to.equal(true);
+ });
+
+ it('should emit same value when last_value_changed is missing or invalid', async () => {
+ const request = sinon.stub().resolves({ result: [{ code: 'switch_1', value: false }] });
+ const emit = sinon.stub();
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ '../../../utils/logger': logger,
+ });
+
+ const context = {
+ connector: { request },
+ gladys: {
+ event: { emit },
+ stateManager: {
+ get: sinon
+ .stub()
+ .onFirstCall()
+ .returns({ last_value: 0 })
+ .onSecondCall()
+ .returns({ last_value: 0, last_value_changed: 'not-a-date' }),
+ },
+ },
+ };
+
+ const device = {
+ external_id: 'tuya:device',
+ params: [{ name: 'LOCAL_OVERRIDE', value: false }],
+ features: [{ external_id: 'tuya:device:switch_1', selector: 'switch-1', category: 'switch', type: 'binary' }],
+ };
+
+ await poll.call(context, device);
+ await poll.call(context, device);
+
+ expect(emit.calledTwice).to.equal(true);
+ });
+
+ it('should skip emit when transformed value is null', async () => {
+ const request = sinon.stub().resolves({ result: [{ code: 'switch_1', value: true }] });
+ const emit = sinon.stub();
+ const logger = { debug: sinon.stub(), warn: sinon.stub() };
+ const { poll } = proxyquire('../../../../services/tuya/lib/tuya.poll', {
+ '../../../utils/logger': logger,
+ './device/tuya.deviceMapping': {
+ readValues: {
+ switch: {
+ binary: () => null,
+ },
+ },
+ },
+ });
+
+ await poll.call(
+ {
+ connector: { request },
+ gladys: { event: { emit } },
+ },
+ {
+ external_id: 'tuya:device',
+ params: [{ name: 'LOCAL_OVERRIDE', value: false }],
+ features: [{ external_id: 'tuya:device:switch_1', category: 'switch', type: 'binary' }],
+ },
+ );
+
+ expect(emit.called).to.equal(false);
});
});
diff --git a/server/test/services/tuya/lib/tuya.saveConfiguration.test.js b/server/test/services/tuya/lib/tuya.saveConfiguration.test.js
index a07c47b576..0ec30ff22d 100644
--- a/server/test/services/tuya/lib/tuya.saveConfiguration.test.js
+++ b/server/test/services/tuya/lib/tuya.saveConfiguration.test.js
@@ -30,16 +30,20 @@ describe('TuyaHandler.saveConfiguration', () => {
accessKey: 'accessKey',
secretKey: 'secretKey',
appAccountId: 'appAccountUID',
+ appUsername: 'user@example.com',
};
const config = await tuyaHandler.saveConfiguration(configuration);
expect(config).to.deep.eq(configuration);
- assert.callCount(gladys.variable.setValue, 4);
+ assert.callCount(gladys.variable.setValue, 7);
assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.ENDPOINT, 'endpoint', serviceId);
assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.ACCESS_KEY, 'accessKey', serviceId);
assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.SECRET_KEY, 'secretKey', serviceId);
assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.APP_ACCOUNT_UID, 'appAccountUID', serviceId);
+ assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.APP_USERNAME, 'user@example.com', serviceId);
+ assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.MANUAL_DISCONNECT, 'false', serviceId);
+ assert.calledWith(gladys.variable.setValue, GLADYS_VARIABLES.LAST_CONNECTED_CONFIG_HASH, '', serviceId);
});
});
diff --git a/server/test/services/tuya/lib/tuya.setValue.fixtures.test.js b/server/test/services/tuya/lib/tuya.setValue.fixtures.test.js
new file mode 100644
index 0000000000..bd8e0ccfe3
--- /dev/null
+++ b/server/test/services/tuya/lib/tuya.setValue.fixtures.test.js
@@ -0,0 +1,62 @@
+const sinon = require('sinon');
+const { expect } = require('chai');
+const proxyquire = require('proxyquire')
+ .noCallThru()
+ .noPreserveCache();
+
+const { loadFixtureCases } = require('../fixtures/fixtureHelper');
+
+describe('TuyaHandler.setValue fixtures', () => {
+ const fixtureCases = loadFixtureCases('setValueLocal');
+
+ it('should load at least one setValue fixture case', () => {
+ expect(fixtureCases.length).to.be.greaterThan(0);
+ });
+
+ fixtureCases.forEach((fixtureCase) => {
+ it(`should set local value for ${fixtureCase.manifest.name} from fixture`, async () => {
+ const connect = sinon.stub().resolves();
+ const set = sinon.stub().resolves();
+ const disconnect = sinon.stub().resolves();
+
+ /**
+ * @description Simple TuyAPI test double used for fixture-driven local setValue tests.
+ * @example
+ * new TuyAPIStub();
+ */
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.set = set;
+ this.disconnect = disconnect;
+ }
+
+ const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': TuyAPIStub,
+ });
+
+ const {
+ device,
+ featureExternalId,
+ inputValue,
+ expectedLocalSet,
+ expectedCloudRequests = 0,
+ } = fixtureCase.manifest.setValueLocal;
+
+ const currentDevice = fixtureCase.load(device);
+ const currentFeature = currentDevice.features.find((feature) => feature.external_id === featureExternalId);
+ const ctx = {
+ connector: { request: sinon.stub().resolves({}) },
+ gladys: {},
+ };
+
+ await setValue.call(ctx, currentDevice, currentFeature, inputValue);
+
+ expect(connect.calledOnce).to.equal(true);
+ expect(set.calledOnce).to.equal(true);
+ expect(disconnect.calledOnce).to.equal(true);
+ expect(set.firstCall.args[0]).to.deep.equal(expectedLocalSet);
+ expect(ctx.connector.request.callCount).to.equal(expectedCloudRequests);
+ });
+ });
+});
diff --git a/server/test/services/tuya/lib/tuya.setValue.test.js b/server/test/services/tuya/lib/tuya.setValue.test.js
index 4cec8115fb..cbc61cbc5a 100644
--- a/server/test/services/tuya/lib/tuya.setValue.test.js
+++ b/server/test/services/tuya/lib/tuya.setValue.test.js
@@ -1,10 +1,14 @@
+/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */
const sinon = require('sinon');
+const proxyquire = require('proxyquire')
+ .noCallThru()
+ .noPreserveCache();
const { assert, fake } = sinon;
const { expect } = require('chai');
const TuyaHandler = require('../../../../services/tuya/lib/index');
-const { API } = require('../../../../services/tuya/lib/utils/tuya.constants');
+const { API, DEVICE_PARAM_NAME } = require('../../../../services/tuya/lib/utils/tuya.constants');
const { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_TYPES } = require('../../../../utils/constants');
const { BadParameters } = require('../../../../utils/coreErrors');
@@ -88,4 +92,322 @@ describe('TuyaHandler.setValue', () => {
body: { commands: [{ code: 'switch_0', value: true }] },
});
});
+
+ it('should call local tuyapi when local params are set', async () => {
+ const connect = sinon.stub().resolves();
+ const set = sinon.stub().resolves();
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.set = set;
+ this.disconnect = disconnect;
+ }
+ const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+
+ const device = {
+ params: [
+ { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' },
+ { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' },
+ { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' },
+ { name: DEVICE_PARAM_NAME.CLOUD_IP, value: '1.1.1.1' },
+ { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true },
+ ],
+ };
+ const deviceFeature = {
+ external_id: 'tuya:device:switch_1',
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ };
+
+ const ctx = {
+ connector: { request: sinon.stub() },
+ gladys: {},
+ };
+
+ await setValue.call(ctx, device, deviceFeature, 1);
+
+ expect(connect.calledOnce).to.equal(true);
+ expect(set.calledOnce).to.equal(true);
+ expect(disconnect.calledOnce).to.equal(true);
+ expect(ctx.connector.request.called).to.equal(false);
+ });
+
+ it('should call local tuyapi-newgen for protocol 3.5', async () => {
+ const connect = sinon.stub().resolves();
+ const set = sinon.stub().resolves();
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ throw new Error('tuyapi should not be used for protocol 3.5');
+ }
+ function TuyAPINewGenStub() {
+ this.connect = connect;
+ this.set = set;
+ this.disconnect = disconnect;
+ }
+ const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': TuyAPINewGenStub,
+ });
+
+ const device = {
+ params: [
+ { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' },
+ { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' },
+ { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.5' },
+ { name: DEVICE_PARAM_NAME.CLOUD_IP, value: '1.1.1.1' },
+ { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true },
+ ],
+ };
+ const deviceFeature = {
+ external_id: 'tuya:device:switch_1',
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ };
+
+ const ctx = {
+ connector: { request: sinon.stub() },
+ gladys: {},
+ };
+
+ await setValue.call(ctx, device, deviceFeature, 1);
+
+ expect(connect.calledOnce).to.equal(true);
+ expect(set.calledOnce).to.equal(true);
+ expect(disconnect.calledOnce).to.equal(true);
+ expect(ctx.connector.request.called).to.equal(false);
+ });
+
+ it('should call local tuyapi with switch code', async () => {
+ const connect = sinon.stub().resolves();
+ const set = sinon.stub().resolves();
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.set = set;
+ this.disconnect = disconnect;
+ }
+ const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+
+ const device = {
+ params: [
+ { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' },
+ { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' },
+ { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' },
+ { name: DEVICE_PARAM_NAME.CLOUD_IP, value: '1.1.1.1' },
+ { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true },
+ ],
+ };
+ const deviceFeature = {
+ external_id: 'tuya:device:switch',
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ };
+
+ const ctx = {
+ connector: { request: sinon.stub() },
+ gladys: {},
+ };
+
+ await setValue.call(ctx, device, deviceFeature, 1);
+
+ expect(connect.calledOnce).to.equal(true);
+ expect(set.calledOnce).to.equal(true);
+ expect(disconnect.calledOnce).to.equal(true);
+ expect(ctx.connector.request.called).to.equal(false);
+ });
+
+ it('should fallback to cloud when local override is false', async () => {
+ const connect = sinon.stub().resolves();
+ const set = sinon.stub().resolves();
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.set = set;
+ this.disconnect = disconnect;
+ }
+ const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+
+ const device = {
+ params: [
+ { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' },
+ { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' },
+ { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' },
+ { name: DEVICE_PARAM_NAME.CLOUD_IP, value: '10.0.0.2' },
+ { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: 'false' },
+ ],
+ };
+ const deviceFeature = {
+ external_id: 'tuya:device:switch_1',
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ };
+
+ const ctx = {
+ connector: { request: sinon.stub().resolves({}) },
+ gladys: {},
+ };
+
+ await setValue.call(ctx, device, deviceFeature, 1);
+
+ expect(connect.called).to.equal(false);
+ expect(ctx.connector.request.calledOnce).to.equal(true);
+ });
+
+ it('should fallback to cloud when dps is not mapped', async () => {
+ const connect = sinon.stub().resolves();
+ const set = sinon.stub().resolves();
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.set = set;
+ this.disconnect = disconnect;
+ }
+ const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+
+ const device = {
+ params: [
+ { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' },
+ { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' },
+ { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' },
+ { name: DEVICE_PARAM_NAME.CLOUD_IP, value: '1.1.1.1' },
+ { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true },
+ ],
+ };
+ const deviceFeature = {
+ external_id: 'tuya:device:countdown',
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ };
+
+ const ctx = {
+ connector: { request: sinon.stub().resolves({}) },
+ gladys: {},
+ };
+
+ await setValue.call(ctx, device, deviceFeature, 1);
+
+ expect(connect.called).to.equal(false);
+ expect(ctx.connector.request.calledOnce).to.equal(true);
+ });
+
+ it('should fallback to cloud when local call fails', async () => {
+ const connect = sinon.stub().rejects(new Error('local error'));
+ const set = sinon.stub().resolves();
+ const disconnect = sinon.stub().resolves();
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.set = set;
+ this.disconnect = disconnect;
+ }
+ const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+
+ const device = {
+ params: [
+ { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' },
+ { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' },
+ { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' },
+ { name: DEVICE_PARAM_NAME.CLOUD_IP, value: '1.1.1.1' },
+ { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true },
+ ],
+ };
+ const deviceFeature = {
+ external_id: 'tuya:device:switch_1',
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ };
+
+ const ctx = {
+ connector: { request: sinon.stub().resolves({}) },
+ gladys: {},
+ };
+
+ await setValue.call(ctx, device, deviceFeature, 1);
+
+ expect(ctx.connector.request.calledOnce).to.equal(true);
+ });
+
+ it('should throw when command is empty', async () => {
+ const device = {
+ params: [
+ { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' },
+ { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' },
+ { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' },
+ { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true },
+ ],
+ };
+ const deviceFeature = {
+ external_id: 'tuya:device:',
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ };
+
+ const ctx = {
+ connector: { request: sinon.stub().resolves({}) },
+ gladys: {},
+ };
+
+ try {
+ await tuyaHandler.setValue.call(ctx, device, deviceFeature, 1);
+ expect.fail('Expected setValue to throw');
+ } catch (error) {
+ expect(error.message).to.include('have no command');
+ }
+ expect(ctx.connector.request.called).to.equal(false);
+ });
+
+ it('should log disconnect failures and still return on local success', async () => {
+ const connect = sinon.stub().resolves();
+ const set = sinon.stub().resolves();
+ const disconnect = sinon.stub().rejects(new Error('disconnect error'));
+ function TuyAPIStub() {
+ this.connect = connect;
+ this.set = set;
+ this.disconnect = disconnect;
+ }
+ const { setValue } = proxyquire('../../../../services/tuya/lib/tuya.setValue', {
+ tuyapi: TuyAPIStub,
+ '@demirdeniz/tuyapi-newgen': function TuyAPINewGenStub() {},
+ });
+
+ const device = {
+ params: [
+ { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '10.0.0.2' },
+ { name: DEVICE_PARAM_NAME.LOCAL_KEY, value: 'key' },
+ { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' },
+ { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true },
+ ],
+ };
+ const deviceFeature = {
+ external_id: 'tuya:device:switch_1',
+ category: DEVICE_FEATURE_CATEGORIES.SWITCH,
+ type: DEVICE_FEATURE_TYPES.SWITCH.BINARY,
+ };
+
+ const ctx = {
+ connector: { request: sinon.stub() },
+ gladys: {},
+ };
+
+ await setValue.call(ctx, device, deviceFeature, 1);
+
+ expect(connect.calledOnce).to.equal(true);
+ expect(set.calledOnce).to.equal(true);
+ expect(disconnect.calledOnce).to.equal(true);
+ expect(ctx.connector.request.called).to.equal(false);
+ });
});
diff --git a/server/test/services/tuya/lib/utils/tuya.cloudStrategy.test.js b/server/test/services/tuya/lib/utils/tuya.cloudStrategy.test.js
new file mode 100644
index 0000000000..0e27571c5c
--- /dev/null
+++ b/server/test/services/tuya/lib/utils/tuya.cloudStrategy.test.js
@@ -0,0 +1,22 @@
+const { expect } = require('chai');
+
+const { DEVICE_TYPES } = require('../../../../../services/tuya/lib/mappings');
+const { resolveCloudReadStrategy } = require('../../../../../services/tuya/lib/utils/tuya.cloudStrategy');
+
+describe('Tuya cloud strategy utils', () => {
+ it('should ignore empty cloud codes and return no strategy', () => {
+ const strategy = resolveCloudReadStrategy(
+ {
+ specifications: {
+ status: [{ code: '' }],
+ },
+ thing_model: {
+ services: [{ properties: [{ code: ' ' }] }],
+ },
+ },
+ DEVICE_TYPES.SMART_SOCKET,
+ );
+
+ expect(strategy).to.equal(null);
+ });
+});
diff --git a/server/test/services/tuya/lib/utils/tuya.deviceParams.test.js b/server/test/services/tuya/lib/utils/tuya.deviceParams.test.js
new file mode 100644
index 0000000000..1850829ca1
--- /dev/null
+++ b/server/test/services/tuya/lib/utils/tuya.deviceParams.test.js
@@ -0,0 +1,189 @@
+/* eslint-disable require-jsdoc, jsdoc/require-jsdoc */
+const { expect } = require('chai');
+
+const {
+ applyExistingLocalOverride,
+ applyExistingLocalParams,
+ getParamValue,
+ normalizeExistingDevice,
+ updateDiscoveredDeviceWithLocalInfo,
+ upsertParam,
+} = require('../../../../../services/tuya/lib/utils/tuya.deviceParams');
+const { DEVICE_PARAM_NAME } = require('../../../../../services/tuya/lib/utils/tuya.constants');
+
+describe('Tuya device params utils', () => {
+ it('should upsert params', () => {
+ const params = [{ name: 'test', value: 1 }];
+ upsertParam(params, 'test', 2);
+ expect(params[0].value).to.equal(2);
+ upsertParam(params, 'new', 'value');
+ expect(params.find((param) => param.name === 'new').value).to.equal('value');
+ });
+
+ it('should ignore upsert when value is null or undefined', () => {
+ const params = [{ name: 'test', value: 1 }];
+ upsertParam(params, 'test', null);
+ upsertParam(params, 'other', undefined);
+ expect(params).to.deep.equal([{ name: 'test', value: 1 }]);
+ });
+
+ it('should get param value', () => {
+ const value = getParamValue([{ name: 'A', value: 42 }], 'A');
+ expect(value).to.equal(42);
+ expect(getParamValue([{ name: 'A', value: 42 }], 'B')).to.equal(undefined);
+ expect(getParamValue(null, 'A')).to.equal(undefined);
+ });
+
+ it('should normalize existing device local override', () => {
+ const device = {
+ params: [
+ { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: '1' },
+ { name: 'OTHER', value: 'x' },
+ ],
+ };
+ const normalized = normalizeExistingDevice(device);
+ const override = normalized.params.find((param) => param.name === DEVICE_PARAM_NAME.LOCAL_OVERRIDE);
+ expect(override.value).to.equal(true);
+ const other = normalized.params.find((param) => param.name === 'OTHER');
+ expect(other.value).to.equal('x');
+ });
+
+ it('should get param value using device param constants', () => {
+ const params = [
+ { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '1.1.1.1' },
+ { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' },
+ ];
+ expect(getParamValue(params, DEVICE_PARAM_NAME.IP_ADDRESS)).to.equal('1.1.1.1');
+ expect(getParamValue(params, 'MISSING')).to.equal(undefined);
+ });
+
+ it('should not normalize local override when value is null', () => {
+ const device = {
+ params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: null }],
+ };
+ const normalized = normalizeExistingDevice(device);
+ const override = normalized.params.find((param) => param.name === DEVICE_PARAM_NAME.LOCAL_OVERRIDE);
+ expect(override.value).to.equal(null);
+ });
+
+ it('should return device when normalizeExistingDevice has no params', () => {
+ const device = { id: 'device' };
+ const normalized = normalizeExistingDevice(device);
+ expect(normalized).to.equal(device);
+ });
+
+ it('should update discovered device with local info', () => {
+ const device = { ip: 'old', params: [] };
+ const localInfo = { ip: '1.1.1.1', version: '3.3', productKey: 'pkey' };
+ const updated = updateDiscoveredDeviceWithLocalInfo(device, localInfo);
+ const ipParam = updated.params.find((param) => param.name === DEVICE_PARAM_NAME.IP_ADDRESS);
+ const protocolParam = updated.params.find((param) => param.name === DEVICE_PARAM_NAME.PROTOCOL_VERSION);
+ const productKeyParam = updated.params.find((param) => param.name === DEVICE_PARAM_NAME.PRODUCT_KEY);
+
+ expect(updated.ip).to.equal('1.1.1.1');
+ expect(updated.protocol_version).to.equal('3.3');
+ expect(updated.product_key).to.equal('pkey');
+ expect(ipParam.value).to.equal('1.1.1.1');
+ expect(protocolParam.value).to.equal('3.3');
+ expect(productKeyParam.value).to.equal('pkey');
+ });
+
+ it('should keep protocol and product key when local info is partial', () => {
+ const device = { ip: 'old', protocol_version: '3.3', product_key: 'pkey', params: [] };
+ const localInfo = { ip: '2.2.2.2' };
+ const updated = updateDiscoveredDeviceWithLocalInfo(device, localInfo);
+ expect(updated.ip).to.equal('2.2.2.2');
+ expect(updated.protocol_version).to.equal('3.3');
+ expect(updated.product_key).to.equal('pkey');
+ });
+
+ it('should return device when no local info is provided', () => {
+ const device = { ip: 'old' };
+ const updated = updateDiscoveredDeviceWithLocalInfo(device, null);
+ expect(updated).to.equal(device);
+ });
+
+ it('should return device when updateDiscoveredDeviceWithLocalInfo has no device', () => {
+ const updated = updateDiscoveredDeviceWithLocalInfo(null, { ip: '1.1.1.1' });
+ expect(updated).to.equal(null);
+ });
+
+ it('should apply existing local params', () => {
+ const device = { ip: 'old', protocol_version: '3.1', local_override: false, params: [] };
+ const existingDevice = {
+ params: [
+ { name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '2.2.2.2' },
+ { name: DEVICE_PARAM_NAME.PROTOCOL_VERSION, value: '3.3' },
+ { name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true },
+ ],
+ };
+ const updated = applyExistingLocalParams(device, existingDevice);
+ expect(updated.ip).to.equal('2.2.2.2');
+ expect(updated.protocol_version).to.equal('3.3');
+ expect(updated.local_override).to.equal(true);
+ });
+
+ it('should keep device values when existing params are not an array', () => {
+ const device = { ip: 'old', protocol_version: '3.1', local_override: false, params: [] };
+ const existingDevice = { params: null };
+ const updated = applyExistingLocalParams(device, existingDevice);
+ expect(updated.ip).to.equal('old');
+ expect(updated.protocol_version).to.equal('3.1');
+ expect(updated.local_override).to.equal(false);
+ });
+
+ it('should normalize local override when applying existing params', () => {
+ const device = { ip: 'old', protocol_version: '3.1', local_override: true, params: [] };
+ const existingDevice = {
+ params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: 'false' }],
+ };
+ const updated = applyExistingLocalParams(device, existingDevice);
+ expect(updated.local_override).to.equal(false);
+ });
+
+ it('should keep device values when existing params are missing', () => {
+ const device = { ip: 'old', protocol_version: '3.1', local_override: false, params: [] };
+ const existingDevice = { params: [] };
+ const updated = applyExistingLocalParams(device, existingDevice);
+ expect(updated.ip).to.equal('old');
+ expect(updated.protocol_version).to.equal('3.1');
+ expect(updated.local_override).to.equal(false);
+ });
+
+ it('should return device when applyExistingLocalParams has no existing device', () => {
+ const device = { ip: 'old', protocol_version: '3.1', local_override: false, params: [] };
+ const updated = applyExistingLocalParams(device, null);
+ expect(updated).to.equal(device);
+ });
+
+ it('should apply existing local override when present', () => {
+ const device = { params: [] };
+ const existingDevice = { params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: true }] };
+ const updated = applyExistingLocalOverride(device, existingDevice);
+ const overrideParam = updated.params.find((param) => param.name === DEVICE_PARAM_NAME.LOCAL_OVERRIDE);
+ expect(updated.local_override).to.equal(true);
+ expect(overrideParam.value).to.equal(true);
+ });
+
+ it('should normalize existing local override when applying override', () => {
+ const device = { params: [] };
+ const existingDevice = { params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: 'false' }] };
+ const updated = applyExistingLocalOverride(device, existingDevice);
+ const overrideParam = updated.params.find((param) => param.name === DEVICE_PARAM_NAME.LOCAL_OVERRIDE);
+ expect(updated.local_override).to.equal(false);
+ expect(overrideParam.value).to.equal(false);
+ });
+
+ it('should return device when no local override is present', () => {
+ const device = { params: [] };
+ const existingDevice = { params: [{ name: DEVICE_PARAM_NAME.IP_ADDRESS, value: '1.1.1.1' }] };
+ const updated = applyExistingLocalOverride(device, existingDevice);
+ expect(updated).to.equal(device);
+ });
+
+ it('should return device when applyExistingLocalOverride has no existing params', () => {
+ const device = { params: [] };
+ const updated = applyExistingLocalOverride(device, null);
+ expect(updated).to.equal(device);
+ });
+});
diff --git a/server/test/services/tuya/lib/utils/tuya.report.test.js b/server/test/services/tuya/lib/utils/tuya.report.test.js
new file mode 100644
index 0000000000..2331a05084
--- /dev/null
+++ b/server/test/services/tuya/lib/utils/tuya.report.test.js
@@ -0,0 +1,50 @@
+const { expect } = require('chai');
+
+const {
+ buildCloudReport,
+ mergeTuyaReport,
+ withTuyaReport,
+} = require('../../../../../services/tuya/lib/utils/tuya.report');
+
+describe('Tuya report utils', () => {
+ it('should return base report when merge patch is missing', () => {
+ const existingReport = {
+ cloud: {
+ assembled: {
+ specifications: { from: 'current' },
+ },
+ },
+ local: {
+ scan: { ip: '1.1.1.1' },
+ },
+ };
+
+ const merged = mergeTuyaReport(existingReport, null);
+
+ expect(merged.schema_version).to.equal(2);
+ expect(merged.cloud.assembled.specifications).to.deep.equal({ from: 'current' });
+ expect(merged.local.scan).to.deep.equal({ ip: '1.1.1.1' });
+ });
+
+ it('should return same device when withTuyaReport is called without device', () => {
+ expect(withTuyaReport(null, { local: { scan: { ip: '2.2.2.2' } } })).to.equal(null);
+ });
+
+ it('should keep null raw report entries when cloud data is missing', () => {
+ const report = buildCloudReport({
+ deviceId: 'device-1',
+ listDeviceEntry: null,
+ specResult: null,
+ detailsResult: undefined,
+ propsResult: null,
+ modelResult: undefined,
+ device: {},
+ });
+
+ expect(report.cloud.raw.device_list_entry).to.equal(null);
+ expect(report.cloud.raw.device_specification).to.equal(null);
+ expect(report.cloud.raw.device_details).to.equal(null);
+ expect(report.cloud.raw.thing_shadow_properties).to.equal(null);
+ expect(report.cloud.raw.thing_model).to.equal(null);
+ });
+});
diff --git a/server/test/services/tuya/tuya.mock.test.js b/server/test/services/tuya/tuya.mock.test.js
index e8e0c4071f..fc1515318c 100644
--- a/server/test/services/tuya/tuya.mock.test.js
+++ b/server/test/services/tuya/tuya.mock.test.js
@@ -6,6 +6,7 @@ const client = {
const TuyaContext = function TuyaContext() {
this.client = client;
+ this.request = sinon.stub().resolves({ result: { list: [] }, success: true });
};
module.exports = {
diff --git a/server/utils/constants.js b/server/utils/constants.js
index fbacbbbfe8..f01cbd7c41 100644
--- a/server/utils/constants.js
+++ b/server/utils/constants.js
@@ -729,6 +729,7 @@ const DEVICE_FEATURE_TYPES = {
BINARY: 'binary',
POWER: 'power',
ENERGY: 'energy',
+ EXPORT_INDEX: 'export-index',
VOLTAGE: 'voltage',
CURRENT: 'current',
INDEX: 'index',
@@ -1364,6 +1365,7 @@ const WEBSOCKET_MESSAGE_TYPES = {
TUYA: {
STATUS: 'tuya.status',
DISCOVER: 'tuya.discover',
+ ERROR: 'tuya.error',
},
NETATMO: {
STATUS: 'netatmo.status',