Skip to content

Tuya - Add support for LSC Power Plug FR smart socket - PR7#2476

Open
Terdious wants to merge 98 commits intoGladysAssistant:masterfrom
Terdious:tuya-lsc-power-plug-fr-power-meter
Open

Tuya - Add support for LSC Power Plug FR smart socket - PR7#2476
Terdious wants to merge 98 commits intoGladysAssistant:masterfrom
Terdious:tuya-lsc-power-plug-fr-power-meter

Conversation

@Terdious
Copy link
Copy Markdown
Contributor

@Terdious Terdious commented Mar 2, 2026

Pull Request check-list

To ensure your Pull Request can be accepted as fast as possible, make sure to review and check all of these items:

  • If your changes affect the code, did you write the tests?
  • Are tests passing? (npm test on both front/server)
  • Is the linter passing? (npm run eslint on both front/server)
  • Did you run prettier? (npm run prettier on both front/server)
  • If you are adding a new feature/service, did you run the integration comparator? (npm run compare-translations on front)
  • Did you test this pull request in real life? With real devices? If this development is a big feature or a new service, we recommend that you provide a Docker image to the community (forum) for testing before merging.
  • If your changes modify the API (REST or Node.js), did you modify the API documentation? (Documentation is based on comments in code)
  • If you are adding a new features/services which needs explanation, did you modify the user documentation? See the GitHub repo and the website.
  • Did you add fake requests data for the demo mode (front/src/config/demo.js) so that the demo website is working without a backend? (if needed) See https://demo.gladysassistant.com.

NOTE: these things are not required to open a PR and can be done afterwards / while the PR is open.

Description of change

Summary

  • Add support for the LSC Power Plug FR Tuya smart socket reported in issue #2460.
  • Extend the existing Smart Socket support with explicit cloud and local mappings for this device variant, including power metering and child lock handling.
  • Keep this stacked PR aligned with the hardening work already applied on top of tuya-follow-up-hardening.
  • Carry the shared Tuya cloud shadow-read support propagated from the lower Smart Meter and hardening branches so this branch stays consistent with the stack.

Details

  • Server (prod):
    • add explicit Smart Socket mapping support for the LSC Power Plug FR variant
    • support the device-specific cloud and local datapoints required for switch state, child lock, energy, current, power, and voltage
    • keep the local mapping explicit in the dedicated Smart Socket mapping file instead of relying on runtime DPS inference
    • support Thing Model shadow cloud reads for Tuya devices that do not expose legacy cloud status specifications
  • Server tests:
    • add a complete fixture set for smart-socket-b61eihfqeaexn54g
    • cover cloud status, local DPS mapping, expected converted device shape, expected events, poll behavior, and local setValue
    • align Smart Socket fixture expectations with the explicit local mapping approach
    • extend generic Tuya conversion, discovery and polling tests to cover persisted cloud read strategy behavior
    • include the Smart Meter shadow-read fixture updates and the follow-up hardening tests already propagated from lower branches so this PR remains stack-consistent

Why this matters

  • This device is a real-world Smart Socket variant that exposes a slightly different Tuya datapoint layout than the existing reference implementation.
  • Supporting it through explicit mappings keeps the onboarding model predictable and matches the project rule that supported local mappings should live in the dedicated mapping files.
  • Including the already-validated shared cloud-read support avoids drift in the stacked PR chain and keeps Thing Model-only Tuya devices readable from the cloud.

Scope

Compared to tuya-follow-up-hardening:

  • Server (prod)

    • +147 lines / -29 lines
  • Server Tests

    • +1110 lines / -214 lines
  • No front changes.

  • No translation changes.

  • No package changes.

  • Total

    • 31 files changed
    • +1257 lines / -243 lines

Summary by CodeRabbit

Release Notes

  • New Features
    • Added local device polling for Tuya devices, enabling faster response times without cloud dependency.
    • Introduced local network discovery to automatically detect Tuya devices on your network.
    • Added manual connection management with auto-reconnect capabilities.
    • Enhanced support for partially managed device features with improved error messaging.
    • Added GitHub issue workflow to help report unsupported device features.
    • Extended multi-language support with German and French translations.

Terdious and others added 30 commits February 11, 2026 15:58
…er models

- Added support for air conditioning devices with new mappings and DPS configurations.
- Introduced local polling for Tuya devices to improve responsiveness.
- Enhanced device conversion logic to include additional parameters such as cloud IP and local override.
- Updated feature conversion to utilize advanced DPS mappings for air conditioning.
- Implemented new models for air conditioning and power meter, including specific feature mappings.
- Improved error handling and logging for local polling and device value setting.
- Added unit tests for new feature mappings and conversion logic.
… champ d'erreur dans le payload de l'événement WebSocket
…s et ajouter des tests pour la gestion des appareils locaux
…eurs de port et mise à jour des traductions
…re des liens vers la documentation et les options de connexion
…réation de rapports GitHub pour les appareils Tuya
…l disconnect features

- Added new translations for connection status messages in German, English, and French.
- Implemented API endpoints to get Tuya connection status and to manually disconnect from the Tuya cloud.
- Updated the Tuya service to handle automatic reconnection logic and manual disconnect state.
- Enhanced the SetupTab component to reflect connection status and provide a disconnect button.
- Added tests for the new functionality, including status retrieval and manual disconnect.
- Implemented device ranking and sorting in DiscoverTab for better user experience.
- Added loading indicators and improved UI feedback during device scanning.
- Refactored local polling logic to update discovered devices with local information.
- Introduced utility functions for managing device parameters, including upserting and normalizing values.
- Enhanced local scan response handling to merge existing device parameters.
- Updated tests to cover new functionality and ensure reliability of device management.
…age, ajouter des tests pour la reconnexion automatique et la découverte des appareils
… des paramètres dans le code de configuration Tuya
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 2, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR introduces comprehensive local polling and local area network discovery for Tuya devices, along with device-specific mappings for smart sockets and smart meters. It adds backend APIs for local operations, frontend UI components for device management, internationalization support across multiple languages, and extensive test fixtures and coverage.

Changes

Cohort / File(s) Summary
Internationalization
front/src/config/i18n/de.json, front/src/config/i18n/en.json, front/src/config/i18n/fr.json
Added extensive new i18n keys for Tuya setup (Client ID/Secret, App Username, endpoints), device labels (ID, local key, protocol version, IP address), local polling UI, discovery messaging, GitHub issue workflow, validation/error messages, and partial feature indicators across German, English, and French locales.
Frontend Discovery & Setup
front/src/routes/integration/all/tuya/discover-page/DiscoverTab.jsx, front/src/routes/integration/all/tuya/discover-page/style.css, front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx
Enhanced discovery tab with local UDP scanning, device sorting, and loading states; refactored setup tab with comprehensive Tuya configuration retrieval, WebSocket-driven status management, client secret visibility toggle, and disconnection workflows. Added CSS for scan loader styling.
Frontend Device Management
front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx, front/src/routes/integration/all/tuya/TuyaLocalPollSection.jsx, front/src/routes/integration/all/tuya/discover-page/TuyaGithubIssueSection.jsx
New local polling UI component with IP mode selection, protocol configuration, and polling action; GitHub issue creation workflow for device support; device box enhancements with local poll integration, partial feature handling, and metadata display (device ID, product ID, product key, local key).
Frontend Local Polling Logic
front/src/routes/integration/all/tuya/commons/localPoll.js, front/src/routes/integration/all/tuya/discover-page/githubIssue.js, front/src/routes/integration/all/tuya/commons/deviceHelpers.js
Added local device polling with protocol selection and retry logic; GitHub issue creation helpers with device sanitization, payload generation, and fixture scaffolding; extended device helpers for online status resolution, unknown specification/DPS detection, and parameter extraction.
Backend Device Mappings
server/services/tuya/lib/mappings/index.js, server/services/tuya/lib/mappings/cloud/global.js, server/services/tuya/lib/mappings/cloud/smart-meter.js, server/services/tuya/lib/mappings/cloud/smart-socket.js, server/services/tuya/lib/mappings/local/global.js, server/services/tuya/lib/mappings/local/smart-meter.js, server/services/tuya/lib/mappings/local/smart-socket.js
Introduced comprehensive device type-specific mappings (SMART_SOCKET, SMART_METER) for cloud and local DPS/code resolution, including ignored codes/DPS lists, code aliases, feature metadata (categories, types, units), and device type detection logic based on specifications and product identifiers.
Backend Device Conversion & Features
server/services/tuya/lib/device/tuya.convertDevice.js, server/services/tuya/lib/device/tuya.convertFeature.js, server/services/tuya/lib/device/tuya.localMapping.js, server/services/tuya/lib/device/tuya.deviceMapping.js
Enhanced device conversion to extract extensive metadata (product ID/key, local key, protocol version, IP), compute device type, build parameters array, and include tuya_mapping with ignored lists; extended feature conversion with options-based device type and ignored code filtering; added local DPS-to-code resolution with alias support and fallback binary feature generation; refactored device mapping with scale handling and boolean normalization.
Backend Local Operations
server/services/tuya/lib/tuya.localPoll.js, server/services/tuya/lib/tuya.localScan.js
Implemented local polling via TuyAPI with protocol version selection, connection retry logic, and DPS validation; local UDP network scanning with port error tracking and device discovery; integration points for updating discovered devices with local poll results.
Backend Core Services
server/services/tuya/lib/index.js, server/services/tuya/lib/tuya.connect.js, server/services/tuya/lib/tuya.disconnect.js, server/services/tuya/lib/tuya.getConfiguration.js, server/services/tuya/lib/tuya.getStatus.js, server/services/tuya/lib/tuya.init.js, server/services/tuya/lib/tuya.manualDisconnect.js, server/services/tuya/lib/tuya.setValue.js
Added auto-reconnect framework with quick reconnect scheduling and interval management; enhanced connect flow with app account validation and configuration hash tracking; disconnect with manual flag support and WebSocket status emission; extended configuration retrieval to include app username and account UID; new getStatus and manualDisconnect methods; added local polling attempt in setValue with protocol-aware TuyAPI selection.
Backend Device Discovery & Polling
server/services/tuya/lib/tuya.discoverDevices.js, server/services/tuya/lib/tuya.loadDevices.js, server/services/tuya/lib/tuya.loadDeviceDetails.js, server/services/tuya/lib/tuya.poll.js
Enhanced device discovery with local parameter merging and existing device augmentation; refactored device loading with pagination (page_no/page_size) and error resilience; extended detail loading with parallel requests (specifications, details, properties, model) and tuya_report generation; comprehensive poll logic with local/cloud mode selection, DPS mapping, feature state emission, and mixed local/cloud fallback.
Backend Utilities & Configuration
server/services/tuya/lib/utils/tuya.constants.js, server/services/tuya/lib/utils/tuya.config.js, server/services/tuya/lib/utils/tuya.normalize.js, server/services/tuya/lib/utils/tuya.deviceParams.js, server/services/tuya/lib/utils/tuya.report.js, server/services/tuya/lib/utils/tuya.cloudStrategy.js
Added new constants (APP_USERNAME, MANUAL_DISCONNECT, LAST_CONNECTED_CONFIG_HASH, DEVICE_PARAM_NAME) and API versions; configuration hash builder for detecting config changes; boolean normalization helper; device parameter management (upsert, retrieval, existing device normalization); comprehensive Tuya report building with cloud/local sections and API response wrapping; cloud read strategy resolution (LEGACY vs SHADOW).
Backend API Controller
server/services/tuya/api/tuya.controller.js
Added four new API endpoints: POST /api/v1/service/tuya/local-poll, POST /api/v1/service/tuya/local-scan, GET /api/v1/service/tuya/status, POST /api/v1/service/tuya/disconnect with corresponding handlers delegating to TuyaHandler methods.
Backend Dependencies
server/services/tuya/package.json
Added dependencies: @demirdeniz/tuyapi-newgen (^8.1.5) and tuyapi (^7.7.1) for local device communication protocols.
Global Constants
server/utils/constants.js
Added WEBSOCKET_MESSAGE_TYPES.TUYA.ERROR for error event messaging.
Test Fixtures - Smart Meter
server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/*
Added comprehensive smart meter fixture (input device, expected device conversion, cloud status, local DPS, expected events, local mapping) with detailed capability definitions and test scenarios for device conversion, cloud polling, and local polling.
Test Fixtures - Smart Socket
server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/*, server/test/services/tuya/fixtures/devices/smart-socket-basic/*
Added two smart socket fixtures with input devices, expected conversions, cloud/local polling responses, events, and local mappings; includes setValueLocal test scenarios.
Test Utilities & Fixtures
server/test/services/tuya/fixtures/fixtureHelper.js
Introduced fixture helper utilities for loading, normalizing, and asserting device conversion results, features, and event structures across test cases.
Test Coverage - Device Operations
server/test/services/tuya/lib/device/tuya.convertDevice.test.js, server/test/services/tuya/lib/device/tuya.convertDevice.fixtures.test.js, server/test/services/tuya/lib/device/tuya.localMapping.test.js, server/test/services/tuya/lib/device/tuya.localMapping.fixtures.test.js, server/test/services/tuya/lib/device/feature/tuya.convertFeature.test.js, server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js
Added extensive test suites for device conversion (field mapping, optional field handling, feature building from thing_model), local DPS-to-code resolution, fallback binary feature generation, feature conversion with device type and ignored codes, and device mapping read/write value handling with scale and boolean normalization.
Test Coverage - Core Services
server/test/services/tuya/lib/tuya.connect.test.js, server/test/services/tuya/lib/tuya.disconnect.test.js, server/test/services/tuya/lib/tuya.getConfiguration.test.js, server/test/services/tuya/lib/tuya.getStatus.test.js, server/test/services/tuya/lib/tuya.init.test.js, server/test/services/tuya/lib/tuya.manualDisconnect.test.js, server/test/services/tuya/lib/tuya.saveConfiguration.test.js
Added test coverage for connection with error mapping, manual disconnect workflows, configuration retrieval with new fields, status computation, initialization with manual disconnect and hash matching, and configuration persistence with new variables.
Test Coverage - Device Discovery & Polling
server/test/services/tuya/lib/tuya.discoverDevices.test.js, server/test/services/tuya/lib/tuya.loadDevices.test.js, server/test/services/tuya/lib/tuya.loadDeviceDetails.test.js, server/test/services/tuya/lib/tuya.poll.test.js, server/test/services/tuya/lib/tuya.poll.fixtures.test.js, server/test/services/tuya/lib/tuya.setValue.test.js, server/test/services/tuya/lib/tuya.setValue.fixtures.test.js
Added tests for device discovery with existing device preservation, pagination with page_no/page_size, detail loading with parallel requests and error handling, comprehensive poll scenarios (cloud, local, mixed), fixture-driven polling validation, local setValue with protocol selection, and fixture-driven setValue behavior.
Test Coverage - Local Operations
server/test/services/tuya/lib/tuya.localPoll.test.js, server/test/services/tuya/lib/tuya.localScan.test.js
Added extensive tests for local polling (parameter validation, protocol selection, timeout handling, socket errors, device updating) and local scanning (UDP discovery, port error tracking, device merging with existing state).
Test Coverage - Utilities
server/test/services/tuya/lib/utils/tuya.deviceParams.test.js, server/test/services/tuya/index.test.js
Added tests for device parameter utilities (upsert, retrieval, existing device normalization) and enhanced TuyaService tests with auto-reconnect scenarios, interval management, and lifecycle handling.
Test Coverage - Controllers
server/test/services/tuya/lib/controllers/tuya.controller.test.js
Extended tests to cover new API endpoints (local-poll, local-scan, status, disconnect) with payload validation and device state updates.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Suggested reviewers

  • Pierre-Gilles

Poem

🐰 A leap through code, device by device,
Local polls dance, so crisp and nice,
Smart sockets hum, and meters sing,
Gladys's Tuya now takes wing! ✨🔌

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 2, 2026

Codecov Report

❌ Patch coverage is 99.82684% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.87%. Comparing base (8db6b0d) to head (44fe379).
⚠️ Report is 8 commits behind head on master.

Files with missing lines Patch % Lines
server/services/tuya/lib/tuya.localPoll.js 98.48% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #2476      +/-   ##
==========================================
+ Coverage   98.79%   98.87%   +0.07%     
==========================================
  Files        1006     1024      +18     
  Lines       17478    18623    +1145     
==========================================
+ Hits        17268    18413    +1145     
  Misses        210      210              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@relativeci
Copy link
Copy Markdown

relativeci bot commented Mar 2, 2026

#4025 Bundle Size — 11.55MiB (+1.25%).

44fe379(current) vs 1aade56 master#4019(baseline)

Warning

Bundle contains 2 duplicate packages – View duplicate packages

Bundle metrics  Change 5 changes Regression 2 regressions
                 Current
#4025
     Baseline
#4019
Regression  Initial JS 6.54MiB(+2.22%) 6.4MiB
Regression  Initial CSS 310.55KiB(+0.02%) 310.49KiB
Change  Cache Invalidation 58.65% 55.98%
No change  Chunks 51 51
No change  Assets 179 179
Change  Modules 1648(+0.3%) 1643
No change  Duplicate Modules 21 21
Change  Duplicate Code 0.93%(-1.06%) 0.94%
No change  Packages 136 136
No change  Duplicate Packages 2 2
Bundle size by type  Change 2 changes Regression 2 regressions
                 Current
#4025
     Baseline
#4019
Regression  JS 8.44MiB (+1.72%) 8.3MiB
No change  IMG 2.66MiB 2.66MiB
Regression  CSS 328.4KiB (+0.02%) 328.34KiB
No change  Fonts 93.55KiB 93.55KiB
No change  Other 18.82KiB 18.82KiB
No change  HTML 13.58KiB 13.58KiB

Bundle analysis reportBranch Terdious:tuya-lsc-power-plug-fr-...Project dashboard


Generated by RelativeCIDocumentationReport issue

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

🧹 Nitpick comments (15)
server/test/services/tuya/lib/tuya.localScan.test.js (1)

55-68: Always restore fake timers with try/finally to avoid cross-test leakage.

These tests restore clocks only on the happy path. If an assertion/promise fails before restore, fake timers can leak and destabilize following tests.

♻️ Suggested pattern
+const withFakeClock = async (fn) => {
+  const clock = sinon.useFakeTimers();
+  try {
+    return await fn(clock);
+  } finally {
+    clock.restore();
+  }
+};

-const clock = sinon.useFakeTimers();
-const promise = localScan(1);
-...
-await clock.tickAsync(1100);
-const result = await promise;
-clock.restore();
+const result = await withFakeClock(async (clock) => {
+  const promise = localScan(1);
+  ...
+  await clock.tickAsync(1100);
+  return promise;
+});

Also applies to: 121-138, 192-205, 248-260, 311-316, 358-363, 408-420, 473-489, 548-563

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/tuya/lib/tuya.localScan.test.js` around lines 55 - 68,
Wrap each test's fake-timer usage in a try/finally so clocks are always
restored: when you call sinon.useFakeTimers() and assign to clock (as seen
around localScan(1), clock.tickAsync(1100) and clock.restore()), move the
clock.restore() into a finally block that always runs after awaiting the
promise/result; apply the same change to the other occurrences (around the
blocks at lines referenced in the review) to prevent timer leakage between
tests.
front/src/routes/integration/all/tuya/commons/deviceHelpers.js (1)

60-68: Unify feature code extraction to avoid parser drift.

getKnownDpsKeys and getUnknownSpecificationCodes parse feature.external_id differently. Centralizing this in one helper will prevent subtle mismatches in future changes.

Also applies to: 99-103

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/routes/integration/all/tuya/commons/deviceHelpers.js` around lines
60 - 68, The feature code extraction is duplicated and inconsistent between
getKnownDpsKeys and getUnknownSpecificationCodes; create a single helper (e.g.,
parseFeatureCode or getFeatureCodeFromExternalId) that takes feature.external_id
(tolerates null/empty), splits on ':' and returns the code at index 2 or null,
then replace the current ad-hoc parsing in getKnownDpsKeys and
getUnknownSpecificationCodes (and the similar logic around lines 99-103) to call
this helper so both functions use the identical extraction logic.
server/test/services/tuya/lib/tuya.saveConfiguration.test.js (1)

27-48: Add a second test for empty/omitted appUsername to avoid stale config regressions.

Given appUsername is optional in UI, a dedicated case for clearing/unset input would harden this path.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/tuya/lib/tuya.saveConfiguration.test.js` around lines 27
- 48, Add a second unit test for tuyaHandler.saveConfiguration that covers the
case where appUsername is empty or omitted: call saveConfiguration with
configuration missing or with appUsername set to '' and assert the returned
config and that gladys.variable.setValue is called for
GLADYS_VARIABLES.APP_USERNAME with an empty string (''), plus the other expected
variable sets (ENDPOINT, ACCESS_KEY, SECRET_KEY, APP_ACCOUNT_UID,
MANUAL_DISCONNECT, LAST_CONNECTED_CONFIG_HASH); ensure the test name and
assertions mirror the existing "should save configuration" test but expect
APP_USERNAME to be cleared to '' to prevent stale config regressions.
server/services/tuya/lib/tuya.getStatus.js (1)

11-15: Fetch Gladys variables in parallel to reduce status latency.

Lines 11–15 are independent lookups and can run concurrently.

Proposed change
-  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 [endpoint, accessKey, secretKey, appAccountId, manualDisconnect] = await Promise.all([
+    this.gladys.variable.getValue(GLADYS_VARIABLES.ENDPOINT, this.serviceId),
+    this.gladys.variable.getValue(GLADYS_VARIABLES.ACCESS_KEY, this.serviceId),
+    this.gladys.variable.getValue(GLADYS_VARIABLES.SECRET_KEY, this.serviceId),
+    this.gladys.variable.getValue(GLADYS_VARIABLES.APP_ACCOUNT_UID, this.serviceId),
+    this.gladys.variable.getValue(GLADYS_VARIABLES.MANUAL_DISCONNECT, this.serviceId),
+  ]);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/tuya.getStatus.js` around lines 11 - 15, The
sequential awaits for fetching Gladys variables (endpoint, accessKey, secretKey,
appAccountId, manualDisconnect) should be converted to a parallel fetch using
Promise.all to reduce latency; update the block in tuya.getStatus.js that
currently calls this.gladys.variable.getValue(GLADYS_VARIABLES.* ,
this.serviceId) five times to a single Promise.all invocation that returns all
five values and then assign them to endpoint, accessKey, secretKey,
appAccountId, and manualDisconnect respectively so the rest of the function uses
the same variable names.
server/services/tuya/lib/tuya.setValue.js (1)

47-47: Make hasLocalConfig explicitly boolean for readability and safer future edits.

Line 47 currently relies on truthy chaining; explicit boolean coercion makes intent clearer.

Proposed change
-  const hasLocalConfig = ipAddress && localKey && protocolVersion && localOverride === true;
+  const hasLocalConfig = Boolean(ipAddress && localKey && protocolVersion && localOverride === true);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/tuya.setValue.js` at line 47, The variable
hasLocalConfig is currently set via truthy chaining; change its assignment to
produce an explicit boolean by coercing the result (e.g., wrap the existing
expression with Boolean(...) or prepend with !!) so hasLocalConfig becomes
true/false explicitly; update the assignment that references ipAddress,
localKey, protocolVersion, and localOverride (where localOverride is compared to
true) to use this boolean coercion.
server/test/services/tuya/lib/tuya.setValue.fixtures.test.js (1)

47-53: Add an explicit guard when fixture featureExternalId is missing.

Line 47 can return undefined; adding a direct assertion gives clearer fixture failure messages than letting setValue fail downstream.

Proposed change
 const currentDevice = fixtureCase.load(device);
 const currentFeature = currentDevice.features.find((feature) => feature.external_id === featureExternalId);
+if (!currentFeature) {
+  throw new Error(`Fixture "${fixtureCase.directoryName}" references unknown feature "${featureExternalId}"`);
+}
 const ctx = {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/tuya/lib/tuya.setValue.fixtures.test.js` around lines 47
- 53, The fixture may yield undefined for currentFeature when using
currentDevice.features.find(...) with featureExternalId; add an explicit
guard/assert (e.g., assert or expect) right after obtaining currentFeature to
fail fast with a clear message if featureExternalId is missing, before calling
setValue.call(ctx, currentDevice, currentFeature, inputValue), so tests show a
descriptive fixture error instead of a downstream failure in setValue.
server/test/services/tuya/lib/device/tuya.convertDevice.fixtures.test.js (1)

44-49: Keep degraded fixture mutations consistent with thing_model_raw.

Line 44 through Line 49 strips codes from thing_model, but thing_model_raw remains unchanged. This can make the “feature removed” test brittle if parser precedence changes.

♻️ Proposed refactor
   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),
     }));
   }
+
+  if (typeof input.thing_model_raw === 'string') {
+    try {
+      const parsedRawModel = JSON.parse(input.thing_model_raw);
+      if (parsedRawModel && Array.isArray(parsedRawModel.services)) {
+        parsedRawModel.services = parsedRawModel.services.map((service) => ({
+          ...service,
+          properties: filterEntries(service && service.properties),
+        }));
+        input.thing_model_raw = JSON.stringify(parsedRawModel);
+      }
+    } catch (e) {
+      // keep raw payload as-is if malformed
+    }
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/tuya/lib/device/tuya.convertDevice.fixtures.test.js`
around lines 44 - 49, The test mutates input.thing_model services by stripping
codes via the filterEntries mapping but leaves input.thing_model_raw unchanged,
making the fixture inconsistent; update the fixture mutation to apply the same
transformation to input.thing_model_raw (when it exists and has services) using
the same logic used for input.thing_model (i.e., map services and set properties
= filterEntries(service && service.properties)) so both thing_model and
thing_model_raw are kept in sync for the “feature removed” test.
server/test/services/tuya/index.test.js (1)

169-195: Add a regression test for stop() during an in-flight reconnect attempt.

The current timeout cleanup test is good, but it doesn’t cover the async window where getStatus() resolves after stop() and a new timeout can be scheduled.

🧪 Suggested test shape
+  it('should not schedule a new quick reconnect after stop when attempt resolves late', async () => {
+    const setTimeoutStub = sinon.stub(global, 'setTimeout').callsFake(() => 456);
+    const clearTimeoutStub = sinon.stub(global, 'clearTimeout');
+    let resolveStatus;
+    const pendingStatus = new Promise((resolve) => {
+      resolveStatus = resolve;
+    });
+
+    try {
+      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 inFlight = intervalCallback();
+      await tuyaService.stop();
+      resolveStatus({ configured: true, manual_disconnect: false });
+      await inFlight;
+
+      assert.notCalled(setTimeoutStub);
+      assert.called(clearTimeoutStub);
+    } finally {
+      setTimeoutStub.restore();
+      clearTimeoutStub.restore();
+    }
+  });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/tuya/index.test.js` around lines 169 - 195, Add a
regression test that simulates an in-flight reconnect where
tuyaService.device.getStatus resolves after tuyaService.stop() and could
schedule a new timeout; specifically, stub global.setTimeout and
global.clearTimeout, arrange tuyaService.device.getStatus to be a promise you
resolve after calling tuyaService.stop(), trigger the reconnect flow via
tuyaService.start() and intervalCallback(), then resolve the delayed getStatus
and assert that setTimeout was not left active (or that clearTimeout was called
with the scheduled id) and no extra reconnect occurs; reference the existing
test scaffolding (tuyaService.start, tuyaService.stop,
tuyaService.device.getStatus, intervalCallback, and the setTimeout/clearTimeout
stubs) so the new test mirrors the current "should clear pending quick reconnect
timeouts" structure but injects the delayed resolution to cover the async
window.
server/test/services/tuya/fixtures/fixtureHelper.js (1)

85-91: Direct array access in normalizeEvents could cause cryptic test failures.

The function directly accesses call.args[1] without checking if args exists or has sufficient length. If a test stub is misconfigured, this will throw a confusing error rather than a helpful one.

Given this is test helper code and the learning about keeping utilities lean, this is a minor hardening suggestion.

🛡️ Optional defensive access
 const normalizeEvents = (calls) =>
   sortByKey(
-    calls.map((call) => ({
-      device_feature_external_id: call.args[1].device_feature_external_id,
-      state: call.args[1].state,
-    })),
+    calls.map((call) => {
+      const payload = call && call.args && call.args[1];
+      return {
+        device_feature_external_id: payload && payload.device_feature_external_id,
+        state: payload && payload.state,
+      };
+    }),
   );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/tuya/fixtures/fixtureHelper.js` around lines 85 - 91,
normalizeEvents currently assumes each stubbed call has an args array with at
least two items and directly accesses call.args[1], which can throw confusing
errors when tests misconfigure stubs; update normalizeEvents to defensively
validate call.args and that call.args.length > 1 (or that call.args[1] exists)
before mapping, and either skip malformed entries or throw a clearer error
mentioning normalizeEvents and the offending call, so tests fail with an
actionable message; reference the normalizeEvents function and the calls
parameter when making the change.
server/services/tuya/lib/utils/tuya.report.js (1)

30-68: Repetitive nested access patterns could be simplified with optional chaining.

The createBaseReport function has many repetitive patterns like currentReport.cloud && currentReport.cloud.assembled ? currentReport.cloud.assembled.specifications || null : null. Modern JS optional chaining (?.) would make this more readable.

However, given the learning that utilities should remain lean and focused on actual usage patterns, this is a minor stylistic observation rather than a blocking concern.

♻️ Optional simplification using optional chaining
 const createBaseReport = (currentReportInput = {}) => {
   const currentReport = currentReportInput || {};
+  const cloudAssembled = currentReport.cloud?.assembled;
+  const cloudRaw = currentReport.cloud?.raw;
   return {
     schema_version: currentReport.schema_version || REPORT_SCHEMA_VERSION,
     cloud: {
       assembled: {
-        specifications:
-          currentReport.cloud && currentReport.cloud.assembled
-            ? currentReport.cloud.assembled.specifications || null
-            : null,
+        specifications: cloudAssembled?.specifications ?? null,
         // ... similar for other fields
       },
       // ...
     },
   };
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/utils/tuya.report.js` around lines 30 - 68, The
createBaseReport function contains repetitive nested null-checks (e.g.,
currentReport.cloud && currentReport.cloud.assembled ?
currentReport.cloud.assembled.specifications || null : null); replace these
patterns with optional chaining and nullish coalescing so fields like
schema_version, cloud.assembled.specifications, cloud.assembled.properties,
cloud.assembled.thing_model, cloud.raw.device_list_entry,
cloud.raw.device_specification, cloud.raw.device_details,
cloud.raw.thing_shadow_properties, cloud.raw.thing_model, and local.scan are
accessed as currentReport?.cloud?.assembled?.specifications ?? null (and
similar) to simplify and improve readability while preserving the same
null/undefined fallback behavior; update createBaseReport (and keep
REPORT_SCHEMA_VERSION) accordingly.
server/services/tuya/lib/mappings/index.js (2)

101-122: Inner function normalizeLocalMapping could be hoisted for clarity.

The normalizeLocalMapping function defined inside getLocalMapping is a pure utility function that doesn't depend on closure variables. Hoisting it to module scope would improve readability and allow potential reuse.

This is a minor stylistic suggestion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/mappings/index.js` around lines 101 - 122, Hoist the
inner utility function normalizeLocalMapping to module scope and have
getLocalMapping call that top-level function instead of declaring it inline;
specifically, extract the current normalizeLocalMapping(mapping) logic (creating
strict, codeAliases, dps, ignoredDps) into a module-level function named
normalizeLocalMapping, then remove the inner declaration inside getLocalMapping
and replace its uses (including when returning
normalizeLocalMapping(globalLocal) and
normalizeLocalMapping(definition.LOCAL_MAPPINGS)); keep references to
globalLocal, DEVICE_TYPES.UNKNOWN, DEVICE_TYPE_INDEX, and normalizeCode
unchanged so behavior is identical.

70-88: Clarify the logic for matching device types when requiredCodes is empty.

On Line 82, when requiredCodes.size === 0, hasRequiredCode becomes true, which means any device matching keywords will be typed even without code verification. This seems intentional for devices where codes might not be available, but the logic flow is not immediately obvious.

Consider adding a brief comment to clarify this behavior for future maintainers.

📝 Suggested clarification comment
 const matchDeviceType = (typeDefinition, context) => {
   const { category, productId, modelName, codes } = context;
   if (category && typeDefinition.CATEGORIES.has(category)) {
     return true;
   }
   if (productId && typeDefinition.PRODUCT_IDS.has(productId)) {
     return true;
   }

   const requiredCodes = typeDefinition.REQUIRED_CODES;
   const keywords = typeDefinition.KEYWORDS;

+  // If no required codes are defined, allow keyword matching without code verification
+  // This handles devices where codes may not be available during detection
   const hasRequiredCode = requiredCodes.size === 0 || Array.from(requiredCodes).some((code) => codes.has(code));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/mappings/index.js` around lines 70 - 88, In
matchDeviceType, the logic sets hasRequiredCode to true when
typeDefinition.REQUIRED_CODES is empty which intentionally allows matching by
KEYWORDS alone; add a concise comment above the requiredCodes/hasRequiredCode
calculation explaining that an empty REQUIRED_CODES means "no code-based
restriction — match by keywords only", and mention the related symbols
REQUIRED_CODES, hasRequiredCode and KEYWORDS so future maintainers understand
why the code-check is skipped when REQUIRED_CODES.size === 0.
front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx (2)

486-490: Check if popup is closed before setting location.

After the async checkGithubIssueExists call, the user may have manually closed the popup. Setting popup.location on a closed window could cause issues in some browsers.

🛡️ Check popup state before navigation
-    if (popup) {
+    if (popup && !popup.closed) {
       popup.location = issueUrl;
       return;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx` around lines 486 -
490, After the async checkGithubIssueExists call, ensure the popup window is
still open before setting its location: check that popup is truthy and
popup.closed is false (e.g., if (popup && !popup.closed) { popup.location =
issueUrl; return; }), otherwise fall back to opening a new window with
window.open(issueUrl, '_blank'); update the logic around the popup/issueUrl
handling in TuyaDeviceBox.jsx where popup and issueUrl are used.

706-731: Disabled anchor still has navigable href - consider using javascript:void(0) or preventing default.

When disableGithubIssueButton is true, the anchor still has a valid href pointing to the GitHub URL. While aria-disabled is set and the click handler likely returns early, screen readers and some browsers may still allow navigation.

The current implementation sets href="#" when disabled, which is correct. However, the onClick handler should also explicitly prevent default when disabled to avoid page scroll to top.

🛡️ Ensure default is prevented when disabled
 const renderGithubIssueButton = (labelId, extraClass = '') => (
   <a
     class={cx('btn btn-gray', {
       [extraClass]: !!extraClass,
       disabled: disableGithubIssueButton
     })}
     href={
       disableGithubIssueButton
         ? '#'
         : createGithubUrl(...)
     }
-    onClick={this.handleCreateGithubIssue}
+    onClick={e => {
+      if (disableGithubIssueButton) {
+        e.preventDefault();
+        return;
+      }
+      this.handleCreateGithubIssue(e);
+    }}
     aria-disabled={disableGithubIssueButton}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx` around lines 706 -
731, The anchor rendered by renderGithubIssueButton still allows navigation when
disableGithubIssueButton is true; update the click handling so that in the
onClick handler (handleCreateGithubIssue) you explicitly prevent default and
return early when disableGithubIssueButton is true (e.g., if
(disableGithubIssueButton) { e.preventDefault(); return; }) or change the
disabled href to a non-navigating value like 'javascript:void(0)'; reference
renderGithubIssueButton and handleCreateGithubIssue to locate and apply the fix.
front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx (1)

199-202: Awkward error handling pattern with thrown string constant.

Throwing new Error('TUYA_START_ERROR') only to check for it in the catch block with string comparison is fragile. Consider using a more structured approach or handling the error case directly.

♻️ Alternative approach without throwing
       // start service
       const service = await this.props.httpClient.post('/api/v1/service/tuya/start');
-      if (service && service.status === 'ERROR') {
-        throw new Error('TUYA_START_ERROR');
-      }
+      if (service && service.status === 'ERROR') {
+        this.setState({
+          tuyaSaveSettingsStatus: RequestStatus.Error,
+          tuyaConnectionError: service.error || 'Start service failed',
+          tuyaJustSaved: false,
+          tuyaJustSavedMissing: false
+        });
+        return;
+      }
       this.setState({
         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);
+      const responseMessage =
+        (e && e.response && e.response.data && e.response.data.message) || (e && e.message) || null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx` around lines
199 - 202, The current pattern throws a string-coded Error after calling
this.props.httpClient.post('/api/v1/service/tuya/start') (service and
TUYA_START_ERROR) which is brittle; update SetupTab.jsx to handle the error case
directly instead of throwing a string—either return/handle the failure path when
service.status === 'ERROR' (e.g., set component state, call an error handler, or
show a user message) or throw a structured Error object with a code property
(new Error with error.code = 'TUYA_START_ERROR') so callers can reliably inspect
error.code; locate the service variable and the call to
this.props.httpClient.post('/api/v1/service/tuya/start') and replace the throw
with one of these structured handling approaches.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@front/src/config/i18n/de.json`:
- Around line 1288-1289: The label and placeholder keys appAccountId and
appAccountIdPlaceholder use “User ID” but should read “App Account UID” to match
surrounding UID-based keys (e.g., errorInvalidAppAccountUid); update the value
for appAccountId to the German equivalent of “App Account UID” and change
appAccountIdPlaceholder to the corresponding German placeholder text using “UID”
instead of “User ID” so both are consistent.

In `@front/src/config/i18n/en.json`:
- Around line 1196-1200: Update the awkward "with success" phrasing in the i18n
entries: replace connectedAfterSave value to use "successfully" (e.g.,
"Connected to the Tuya cloud account successfully!" or "Successfully connected
to the Tuya cloud account."), change disconnectSuccess to "Disconnected from the
Tuya cloud account successfully! Local service still works..." and similarly
update any other keys using "with success" (e.g.,
connectedStatus/disconnectedManual if present) to use natural English like
"successfully" or "Successfully connected/disconnected" while preserving the
existing HTML link and punctuation.

In `@front/src/routes/integration/all/tuya/commons/githubIssue.js`:
- Around line 277-305: checkGithubIssueExists currently throws on fetch/response
failures which can break reporting; make the lookup best-effort: catch any
fetch/JSON/response errors (including non-ok responses and AbortError), log the
error, set exists = false (treat as "not found"), write that result into the
githubIssueCache via setGithubIssueCache(query, { exists, timestamp: Date.now()
}) and return false instead of throwing; keep the existing
timeout/AbortController logic and only change the error path in
checkGithubIssueExists so failures degrade gracefully.

In `@server/services/tuya/index.js`:
- Around line 12-15: The stop() flow can still allow an in-flight runAttempt to
schedule new reconnect timeouts after shutdown; add a cancellation token/flag
(e.g., quickReconnectCancelToken) that runAttempt and any async reconnect helper
check before scheduling or pushing into quickReconnectTimeouts, set that token
in stop() and clear it when starting fresh, and ensure stop() also iterates
quickReconnectTimeouts to clearTimeout and sets quickReconnectInProgress=false
and reconnectInterval=null to prevent any post-stop reconnections; update
runAttempt, the quick reconnect scheduler, and any place that pushes to
quickReconnectTimeouts to bail out if the token is set so no new timers are
created after stop().

In `@server/services/tuya/lib/device/tuya.convertDevice.js`:
- Around line 148-150: The poll_frequency is currently set to
DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS whenever normalizedLocalOverride is
truthy, but that should only happen if the device actually has the necessary
local-mode prerequisites (IP, localKey, and protocol). Change the poll_frequency
assignment (the ternary that uses normalizedLocalOverride) so it only returns
EVERY_10_SECONDS when normalizedLocalOverride is true AND the device info
includes ip, localKey, and protocol (e.g., check the same properties used
elsewhere in this module for local connection); otherwise fall back to
EVERY_30_SECONDS. Ensure you reference normalizedLocalOverride, poll_frequency,
and DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS when making the condition change.

In `@server/services/tuya/lib/tuya.connect.js`:
- Around line 60-63: The API path is interpolating raw appAccountId which can
contain spaces or reserved characters; before building the path in the
connector.request call (the path using API.PUBLIC_VERSION_1_0 and
users/${appAccountId}/devices), trim and URI-encode the appAccountId (e.g.,
apply .trim() and encodeURIComponent) and use that encoded value in the
interpolated path so requests are safe for all valid UIDs.

In `@server/services/tuya/lib/tuya.poll.js`:
- Around line 73-79: The equality check in emitFeatureState currently uses
previousValue !== transformedValue which misclassifies NaN as changed; update
the comparison to use Object.is(previousValue, transformedValue) (invert the
logic to set changed = !Object.is(...)) so NaN is treated as equal to NaN and
transient NaN reads aren’t repeatedly emitted while keeping the existing
null/undefined guard and preserving downstream validation in device.saveState().

In `@server/services/tuya/lib/utils/tuya.normalize.js`:
- Around line 2-5: The current normalization checks trimmed lowercased strings
for 'true'/'on' but doesn't handle trimmed numeric-strings like ' 1 '; update
the normalization in tuya.normalize.js to first compute a trimmed string (e.g.,
const s = typeof value === 'string' ? value.trim() : value), then check
truthiness by: return value === true || value === 1 || s === '1' || (typeof s
=== 'string' && ['true','on'].includes(s.toLowerCase())); this ensures ' 1 ' and
other trimmed numeric-strings are treated as true while preserving existing
boolean/number handling.

---

Nitpick comments:
In `@front/src/routes/integration/all/tuya/commons/deviceHelpers.js`:
- Around line 60-68: The feature code extraction is duplicated and inconsistent
between getKnownDpsKeys and getUnknownSpecificationCodes; create a single helper
(e.g., parseFeatureCode or getFeatureCodeFromExternalId) that takes
feature.external_id (tolerates null/empty), splits on ':' and returns the code
at index 2 or null, then replace the current ad-hoc parsing in getKnownDpsKeys
and getUnknownSpecificationCodes (and the similar logic around lines 99-103) to
call this helper so both functions use the identical extraction logic.

In `@front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx`:
- Around line 199-202: The current pattern throws a string-coded Error after
calling this.props.httpClient.post('/api/v1/service/tuya/start') (service and
TUYA_START_ERROR) which is brittle; update SetupTab.jsx to handle the error case
directly instead of throwing a string—either return/handle the failure path when
service.status === 'ERROR' (e.g., set component state, call an error handler, or
show a user message) or throw a structured Error object with a code property
(new Error with error.code = 'TUYA_START_ERROR') so callers can reliably inspect
error.code; locate the service variable and the call to
this.props.httpClient.post('/api/v1/service/tuya/start') and replace the throw
with one of these structured handling approaches.

In `@front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx`:
- Around line 486-490: After the async checkGithubIssueExists call, ensure the
popup window is still open before setting its location: check that popup is
truthy and popup.closed is false (e.g., if (popup && !popup.closed) {
popup.location = issueUrl; return; }), otherwise fall back to opening a new
window with window.open(issueUrl, '_blank'); update the logic around the
popup/issueUrl handling in TuyaDeviceBox.jsx where popup and issueUrl are used.
- Around line 706-731: The anchor rendered by renderGithubIssueButton still
allows navigation when disableGithubIssueButton is true; update the click
handling so that in the onClick handler (handleCreateGithubIssue) you explicitly
prevent default and return early when disableGithubIssueButton is true (e.g., if
(disableGithubIssueButton) { e.preventDefault(); return; }) or change the
disabled href to a non-navigating value like 'javascript:void(0)'; reference
renderGithubIssueButton and handleCreateGithubIssue to locate and apply the fix.

In `@server/services/tuya/lib/mappings/index.js`:
- Around line 101-122: Hoist the inner utility function normalizeLocalMapping to
module scope and have getLocalMapping call that top-level function instead of
declaring it inline; specifically, extract the current
normalizeLocalMapping(mapping) logic (creating strict, codeAliases, dps,
ignoredDps) into a module-level function named normalizeLocalMapping, then
remove the inner declaration inside getLocalMapping and replace its uses
(including when returning normalizeLocalMapping(globalLocal) and
normalizeLocalMapping(definition.LOCAL_MAPPINGS)); keep references to
globalLocal, DEVICE_TYPES.UNKNOWN, DEVICE_TYPE_INDEX, and normalizeCode
unchanged so behavior is identical.
- Around line 70-88: In matchDeviceType, the logic sets hasRequiredCode to true
when typeDefinition.REQUIRED_CODES is empty which intentionally allows matching
by KEYWORDS alone; add a concise comment above the requiredCodes/hasRequiredCode
calculation explaining that an empty REQUIRED_CODES means "no code-based
restriction — match by keywords only", and mention the related symbols
REQUIRED_CODES, hasRequiredCode and KEYWORDS so future maintainers understand
why the code-check is skipped when REQUIRED_CODES.size === 0.

In `@server/services/tuya/lib/tuya.getStatus.js`:
- Around line 11-15: The sequential awaits for fetching Gladys variables
(endpoint, accessKey, secretKey, appAccountId, manualDisconnect) should be
converted to a parallel fetch using Promise.all to reduce latency; update the
block in tuya.getStatus.js that currently calls
this.gladys.variable.getValue(GLADYS_VARIABLES.* , this.serviceId) five times to
a single Promise.all invocation that returns all five values and then assign
them to endpoint, accessKey, secretKey, appAccountId, and manualDisconnect
respectively so the rest of the function uses the same variable names.

In `@server/services/tuya/lib/tuya.setValue.js`:
- Line 47: The variable hasLocalConfig is currently set via truthy chaining;
change its assignment to produce an explicit boolean by coercing the result
(e.g., wrap the existing expression with Boolean(...) or prepend with !!) so
hasLocalConfig becomes true/false explicitly; update the assignment that
references ipAddress, localKey, protocolVersion, and localOverride (where
localOverride is compared to true) to use this boolean coercion.

In `@server/services/tuya/lib/utils/tuya.report.js`:
- Around line 30-68: The createBaseReport function contains repetitive nested
null-checks (e.g., currentReport.cloud && currentReport.cloud.assembled ?
currentReport.cloud.assembled.specifications || null : null); replace these
patterns with optional chaining and nullish coalescing so fields like
schema_version, cloud.assembled.specifications, cloud.assembled.properties,
cloud.assembled.thing_model, cloud.raw.device_list_entry,
cloud.raw.device_specification, cloud.raw.device_details,
cloud.raw.thing_shadow_properties, cloud.raw.thing_model, and local.scan are
accessed as currentReport?.cloud?.assembled?.specifications ?? null (and
similar) to simplify and improve readability while preserving the same
null/undefined fallback behavior; update createBaseReport (and keep
REPORT_SCHEMA_VERSION) accordingly.

In `@server/test/services/tuya/fixtures/fixtureHelper.js`:
- Around line 85-91: normalizeEvents currently assumes each stubbed call has an
args array with at least two items and directly accesses call.args[1], which can
throw confusing errors when tests misconfigure stubs; update normalizeEvents to
defensively validate call.args and that call.args.length > 1 (or that
call.args[1] exists) before mapping, and either skip malformed entries or throw
a clearer error mentioning normalizeEvents and the offending call, so tests fail
with an actionable message; reference the normalizeEvents function and the calls
parameter when making the change.

In `@server/test/services/tuya/index.test.js`:
- Around line 169-195: Add a regression test that simulates an in-flight
reconnect where tuyaService.device.getStatus resolves after tuyaService.stop()
and could schedule a new timeout; specifically, stub global.setTimeout and
global.clearTimeout, arrange tuyaService.device.getStatus to be a promise you
resolve after calling tuyaService.stop(), trigger the reconnect flow via
tuyaService.start() and intervalCallback(), then resolve the delayed getStatus
and assert that setTimeout was not left active (or that clearTimeout was called
with the scheduled id) and no extra reconnect occurs; reference the existing
test scaffolding (tuyaService.start, tuyaService.stop,
tuyaService.device.getStatus, intervalCallback, and the setTimeout/clearTimeout
stubs) so the new test mirrors the current "should clear pending quick reconnect
timeouts" structure but injects the delayed resolution to cover the async
window.

In `@server/test/services/tuya/lib/device/tuya.convertDevice.fixtures.test.js`:
- Around line 44-49: The test mutates input.thing_model services by stripping
codes via the filterEntries mapping but leaves input.thing_model_raw unchanged,
making the fixture inconsistent; update the fixture mutation to apply the same
transformation to input.thing_model_raw (when it exists and has services) using
the same logic used for input.thing_model (i.e., map services and set properties
= filterEntries(service && service.properties)) so both thing_model and
thing_model_raw are kept in sync for the “feature removed” test.

In `@server/test/services/tuya/lib/tuya.localScan.test.js`:
- Around line 55-68: Wrap each test's fake-timer usage in a try/finally so
clocks are always restored: when you call sinon.useFakeTimers() and assign to
clock (as seen around localScan(1), clock.tickAsync(1100) and clock.restore()),
move the clock.restore() into a finally block that always runs after awaiting
the promise/result; apply the same change to the other occurrences (around the
blocks at lines referenced in the review) to prevent timer leakage between
tests.

In `@server/test/services/tuya/lib/tuya.saveConfiguration.test.js`:
- Around line 27-48: Add a second unit test for tuyaHandler.saveConfiguration
that covers the case where appUsername is empty or omitted: call
saveConfiguration with configuration missing or with appUsername set to '' and
assert the returned config and that gladys.variable.setValue is called for
GLADYS_VARIABLES.APP_USERNAME with an empty string (''), plus the other expected
variable sets (ENDPOINT, ACCESS_KEY, SECRET_KEY, APP_ACCOUNT_UID,
MANUAL_DISCONNECT, LAST_CONNECTED_CONFIG_HASH); ensure the test name and
assertions mirror the existing "should save configuration" test but expect
APP_USERNAME to be cleared to '' to prevent stale config regressions.

In `@server/test/services/tuya/lib/tuya.setValue.fixtures.test.js`:
- Around line 47-53: The fixture may yield undefined for currentFeature when
using currentDevice.features.find(...) with featureExternalId; add an explicit
guard/assert (e.g., assert or expect) right after obtaining currentFeature to
fail fast with a clear message if featureExternalId is missing, before calling
setValue.call(ctx, currentDevice, currentFeature, inputValue), so tests show a
descriptive fixture error instead of a downstream failure in setValue.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 8db6b0d and 3171274.

⛔ Files ignored due to path filters (1)
  • server/services/tuya/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (98)
  • front/src/config/i18n/de.json
  • front/src/config/i18n/en.json
  • front/src/config/i18n/fr.json
  • front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx
  • front/src/routes/integration/all/tuya/commons/deviceHelpers.js
  • front/src/routes/integration/all/tuya/commons/githubIssue.js
  • front/src/routes/integration/all/tuya/discover-page/DiscoverTab.jsx
  • front/src/routes/integration/all/tuya/discover-page/style.css
  • front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx
  • server/services/tuya/api/tuya.controller.js
  • server/services/tuya/index.js
  • server/services/tuya/lib/device/tuya.convertDevice.js
  • server/services/tuya/lib/device/tuya.convertFeature.js
  • server/services/tuya/lib/device/tuya.deviceMapping.js
  • server/services/tuya/lib/device/tuya.localMapping.js
  • server/services/tuya/lib/index.js
  • server/services/tuya/lib/mappings/cloud/global.js
  • server/services/tuya/lib/mappings/cloud/smart-meter.js
  • server/services/tuya/lib/mappings/cloud/smart-socket.js
  • server/services/tuya/lib/mappings/index.js
  • server/services/tuya/lib/mappings/local/global.js
  • server/services/tuya/lib/mappings/local/smart-meter.js
  • server/services/tuya/lib/mappings/local/smart-socket.js
  • server/services/tuya/lib/tuya.connect.js
  • server/services/tuya/lib/tuya.disconnect.js
  • server/services/tuya/lib/tuya.discoverDevices.js
  • server/services/tuya/lib/tuya.getConfiguration.js
  • server/services/tuya/lib/tuya.getStatus.js
  • server/services/tuya/lib/tuya.init.js
  • server/services/tuya/lib/tuya.loadDeviceDetails.js
  • server/services/tuya/lib/tuya.loadDevices.js
  • server/services/tuya/lib/tuya.localPoll.js
  • server/services/tuya/lib/tuya.localScan.js
  • server/services/tuya/lib/tuya.manualDisconnect.js
  • server/services/tuya/lib/tuya.poll.js
  • server/services/tuya/lib/tuya.saveConfiguration.js
  • server/services/tuya/lib/tuya.setValue.js
  • server/services/tuya/lib/utils/tuya.config.js
  • server/services/tuya/lib/utils/tuya.constants.js
  • server/services/tuya/lib/utils/tuya.deviceParams.js
  • server/services/tuya/lib/utils/tuya.normalize.js
  • server/services/tuya/lib/utils/tuya.report.js
  • server/services/tuya/package.json
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/cloud-status.json
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-device.json
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-events.json
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-local-mapping.json
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/input-device.json
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/local-dps.json
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/manifest.js
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/poll-device.json
  • server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/cloud-status.json
  • server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-cloud-events.json
  • server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-device.json
  • server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-local-events.json
  • server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/expected-local-mapping.json
  • server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/input-device.json
  • server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/local-dps.json
  • server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/manifest.js
  • server/test/services/tuya/fixtures/devices/smart-socket-b61eihfqeaexn54g/poll-device.json
  • server/test/services/tuya/fixtures/devices/smart-socket-basic/cloud-status.json
  • server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-cloud-events.json
  • server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-device.json
  • server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-local-events.json
  • server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-local-mapping.json
  • server/test/services/tuya/fixtures/devices/smart-socket-basic/input-device.json
  • server/test/services/tuya/fixtures/devices/smart-socket-basic/local-dps.json
  • server/test/services/tuya/fixtures/devices/smart-socket-basic/manifest.js
  • server/test/services/tuya/fixtures/devices/smart-socket-basic/poll-device.json
  • server/test/services/tuya/fixtures/fixtureHelper.js
  • server/test/services/tuya/index.test.js
  • server/test/services/tuya/lib/controllers/tuya.controller.test.js
  • server/test/services/tuya/lib/device/feature/tuya.convertFeature.test.js
  • server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js
  • server/test/services/tuya/lib/device/tuya.convertDevice.fixtures.test.js
  • server/test/services/tuya/lib/device/tuya.convertDevice.test.js
  • server/test/services/tuya/lib/device/tuya.localMapping.fixtures.test.js
  • server/test/services/tuya/lib/device/tuya.localMapping.test.js
  • server/test/services/tuya/lib/mappings/index.test.js
  • server/test/services/tuya/lib/tuya.connect.test.js
  • server/test/services/tuya/lib/tuya.disconnect.test.js
  • server/test/services/tuya/lib/tuya.discoverDevices.test.js
  • server/test/services/tuya/lib/tuya.getConfiguration.test.js
  • server/test/services/tuya/lib/tuya.getStatus.test.js
  • server/test/services/tuya/lib/tuya.init.test.js
  • server/test/services/tuya/lib/tuya.loadDeviceDetails.test.js
  • server/test/services/tuya/lib/tuya.loadDevices.test.js
  • server/test/services/tuya/lib/tuya.localPoll.test.js
  • server/test/services/tuya/lib/tuya.localScan.test.js
  • server/test/services/tuya/lib/tuya.manualDisconnect.test.js
  • server/test/services/tuya/lib/tuya.poll.fixtures.test.js
  • server/test/services/tuya/lib/tuya.poll.test.js
  • server/test/services/tuya/lib/tuya.saveConfiguration.test.js
  • server/test/services/tuya/lib/tuya.setValue.fixtures.test.js
  • server/test/services/tuya/lib/tuya.setValue.test.js
  • server/test/services/tuya/lib/utils/tuya.deviceParams.test.js
  • server/test/services/tuya/tuya.mock.test.js
  • server/utils/constants.js

Comment on lines +1288 to +1289
"appAccountId": "App Account User ID",
"appAccountIdPlaceholder": "Tuya App User ID eingeben",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use “App Account UID” consistently in the label and placeholder.

Line 1288/Line 1289 use “User ID”, while nearby keys/messages are UID-based (errorInvalidAppAccountUid, setup descriptions). This can confuse users.

✏️ Suggested wording adjustment
-        "appAccountId": "App Account User ID",
-        "appAccountIdPlaceholder": "Tuya App User ID eingeben",
+        "appAccountId": "App Account UID",
+        "appAccountIdPlaceholder": "Tuya App Account UID eingeben",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"appAccountId": "App Account User ID",
"appAccountIdPlaceholder": "Tuya App User ID eingeben",
"appAccountId": "App Account UID",
"appAccountIdPlaceholder": "Tuya App Account UID eingeben",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/config/i18n/de.json` around lines 1288 - 1289, The label and
placeholder keys appAccountId and appAccountIdPlaceholder use “User ID” but
should read “App Account UID” to match surrounding UID-based keys (e.g.,
errorInvalidAppAccountUid); update the value for appAccountId to the German
equivalent of “App Account UID” and change appAccountIdPlaceholder to the
corresponding German placeholder text using “UID” instead of “User ID” so both
are consistent.

Comment on lines +1196 to +1200
"connectedAfterSave": "Connected to the Tuya cloud account with success!",
"connectedStatus": "Connected to the Tuya cloud account.",
"disconnectSuccess": "Disconnected from the Tuya cloud account with success! Local service still works (it will not auto-reconnect when Gladys restarts).",
"disconnectedUnexpected": "You have been disconnected from the Tuya cloud! Please check your credentials and your <a href=\"https://platform.tuya.com/cloud/products?productType=all\" target=\"_blank\" rel=\"noopener noreferrer\">Tuya IoT Core</a> trial status.",
"disconnectedManual": "Disconnected from the Tuya cloud! Local service still works (it will not auto-reconnect when Gladys restarts).",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Tighten English phrasing in connection status messages.

A few new messages use “with success,” which reads awkwardly in EN UI copy.

✏️ Suggested text fix
-        "connectedAfterSave": "Connected to the Tuya cloud account with success!",
+        "connectedAfterSave": "Connected to the Tuya cloud account successfully!",
...
-        "disconnectSuccess": "Disconnected from the Tuya cloud account with success! Local service still works (it will not auto-reconnect when Gladys restarts).",
+        "disconnectSuccess": "Disconnected from the Tuya cloud account successfully! Local service still works (it will not auto-reconnect when Gladys restarts).",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/config/i18n/en.json` around lines 1196 - 1200, Update the awkward
"with success" phrasing in the i18n entries: replace connectedAfterSave value to
use "successfully" (e.g., "Connected to the Tuya cloud account successfully!" or
"Successfully connected to the Tuya cloud account."), change disconnectSuccess
to "Disconnected from the Tuya cloud account successfully! Local service still
works..." and similarly update any other keys using "with success" (e.g.,
connectedStatus/disconnectedManual if present) to use natural English like
"successfully" or "Successfully connected/disconnected" while preserving the
existing HTML link and punctuation.

Comment on lines +277 to +305
export const checkGithubIssueExists = async title => {
const query = buildGithubSearchQuery(title);
const cached = githubIssueCache.get(query);
if (cached && Date.now() - cached.timestamp < GITHUB_SEARCH_CACHE_TTL_MS) {
return cached.exists;
}

let response;
if (typeof AbortController === 'undefined') {
response = await fetch(`${GITHUB_SEARCH_API_URL}${encodeURIComponent(query)}`);
} else {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
response = await fetch(`${GITHUB_SEARCH_API_URL}${encodeURIComponent(query)}`, {
signal: controller.signal
});
} finally {
clearTimeout(timeoutId);
}
}
if (!response.ok) {
throw new Error('Github search failed');
}
const data = await response.json();
const exists = Boolean(data && typeof data.total_count === 'number' && data.total_count > 0);
setGithubIssueCache(query, { exists, timestamp: Date.now() });
return exists;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail open when GitHub issue lookup fails.

Line 299 throws on API failure, which can block or degrade the unsupported-device reporting flow during rate limits/network errors. This check should be best-effort and default to “not found”.

🛠️ Suggested resilient handling
 export const checkGithubIssueExists = async title => {
   const query = buildGithubSearchQuery(title);
   const cached = githubIssueCache.get(query);
   if (cached && Date.now() - cached.timestamp < GITHUB_SEARCH_CACHE_TTL_MS) {
     return cached.exists;
   }

-  let response;
-  if (typeof AbortController === 'undefined') {
-    response = await fetch(`${GITHUB_SEARCH_API_URL}${encodeURIComponent(query)}`);
-  } else {
-    const controller = new AbortController();
-    const timeoutId = setTimeout(() => controller.abort(), 5000);
-    try {
-      response = await fetch(`${GITHUB_SEARCH_API_URL}${encodeURIComponent(query)}`, {
-        signal: controller.signal
-      });
-    } finally {
-      clearTimeout(timeoutId);
-    }
-  }
-  if (!response.ok) {
-    throw new Error('Github search failed');
-  }
-  const data = await response.json();
-  const exists = Boolean(data && typeof data.total_count === 'number' && data.total_count > 0);
-  setGithubIssueCache(query, { exists, timestamp: Date.now() });
-  return exists;
+  try {
+    let response;
+    if (typeof AbortController === 'undefined') {
+      response = await fetch(`${GITHUB_SEARCH_API_URL}${encodeURIComponent(query)}`);
+    } else {
+      const controller = new AbortController();
+      const timeoutId = setTimeout(() => controller.abort(), 5000);
+      try {
+        response = await fetch(`${GITHUB_SEARCH_API_URL}${encodeURIComponent(query)}`, {
+          signal: controller.signal
+        });
+      } finally {
+        clearTimeout(timeoutId);
+      }
+    }
+    if (!response.ok) {
+      setGithubIssueCache(query, { exists: false, timestamp: Date.now() });
+      return false;
+    }
+    const data = await response.json();
+    const exists = Boolean(data && typeof data.total_count === 'number' && data.total_count > 0);
+    setGithubIssueCache(query, { exists, timestamp: Date.now() });
+    return exists;
+  } catch (e) {
+    setGithubIssueCache(query, { exists: false, timestamp: Date.now() });
+    return false;
+  }
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/routes/integration/all/tuya/commons/githubIssue.js` around lines
277 - 305, checkGithubIssueExists currently throws on fetch/response failures
which can break reporting; make the lookup best-effort: catch any
fetch/JSON/response errors (including non-ok responses and AbortError), log the
error, set exists = false (treat as "not found"), write that result into the
githubIssueCache via setGithubIssueCache(query, { exists, timestamp: Date.now()
}) and return false instead of throwing; keep the existing
timeout/AbortController logic and only change the error path in
checkGithubIssueExists so failures degrade gracefully.

Comment on lines +12 to +15
let reconnectInterval = null;
let quickReconnectTimeouts = [];
let quickReconnectInProgress = false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Prevent post-stop reconnects by canceling in-flight quick reconnect runs.

stop() clears existing timers, but an already-running runAttempt can still resume and schedule a new timeout afterward. That can restart reconnect attempts after service shutdown.

🔧 Proposed fix (run token cancellation + final cleanup)
   let reconnectInterval = null;
   let quickReconnectTimeouts = [];
   let quickReconnectInProgress = false;
+  let quickReconnectRunId = 0;
@@
   function clearQuickReconnects() {
+    quickReconnectRunId += 1;
     if (quickReconnectTimeouts.length > 0) {
       quickReconnectTimeouts.forEach((timeoutId) => clearTimeout(timeoutId));
       quickReconnectTimeouts = [];
     }
     quickReconnectInProgress = false;
@@
   function scheduleQuickReconnects() {
     if (quickReconnectInProgress) {
       return Promise.resolve();
     }
     quickReconnectInProgress = true;
+    const runId = quickReconnectRunId;
     let attempts = 0;
 
     const runAttempt = async () => {
+      if (runId !== quickReconnectRunId) {
+        return;
+      }
       attempts += 1;
       const shouldRetry = await tryReconnect();
+      if (runId !== quickReconnectRunId) {
+        return;
+      }
       const isConnecting =
         tuyaHandler.status === STATUS.CONNECTED ||
         tuyaHandler.status === STATUS.CONNECTING ||
         tuyaHandler.status === STATUS.DISCOVERING_DEVICES;
@@
       if (attempts < QUICK_RECONNECT_ATTEMPTS) {
         const timeoutId = setTimeout(runAttempt, QUICK_RECONNECT_DELAY_MS);
@@
         quickReconnectTimeouts.push(timeoutId);
         return;
       }
 
-      quickReconnectInProgress = false;
+      clearQuickReconnects();
     };

Also applies to: 67-100, 128-135

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/index.js` around lines 12 - 15, The stop() flow can
still allow an in-flight runAttempt to schedule new reconnect timeouts after
shutdown; add a cancellation token/flag (e.g., quickReconnectCancelToken) that
runAttempt and any async reconnect helper check before scheduling or pushing
into quickReconnectTimeouts, set that token in stop() and clear it when starting
fresh, and ensure stop() also iterates quickReconnectTimeouts to clearTimeout
and sets quickReconnectInProgress=false and reconnectInterval=null to prevent
any post-stop reconnections; update runAttempt, the quick reconnect scheduler,
and any place that pushes to quickReconnectTimeouts to bail out if the token is
set so no new timers are created after stop().

Comment on lines +148 to +150
poll_frequency: normalizedLocalOverride
? DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS
: DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid forcing 10s polling when local mode prerequisites are incomplete.

Line 148 currently enables fast polling from local_override alone, but local polling also requires IP/localKey/protocol. Otherwise, this increases cloud fallback churn.

🔧 Proposed fix
   const online = tuyaDevice.online !== undefined ? tuyaDevice.online : tuyaDevice.is_online;
   const normalizedLocalOverride = normalizeBoolean(localOverride);
+  const hasCompleteLocalConfig = Boolean(localKey && ip && protocolVersion);
@@
-    poll_frequency: normalizedLocalOverride
+    poll_frequency: normalizedLocalOverride && hasCompleteLocalConfig
       ? DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS
       : DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
poll_frequency: normalizedLocalOverride
? DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS
: DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS,
const online = tuyaDevice.online !== undefined ? tuyaDevice.online : tuyaDevice.is_online;
const normalizedLocalOverride = normalizeBoolean(localOverride);
const hasCompleteLocalConfig = Boolean(localKey && ip && protocolVersion);
poll_frequency: normalizedLocalOverride && hasCompleteLocalConfig
? DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS
: DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/device/tuya.convertDevice.js` around lines 148 -
150, The poll_frequency is currently set to
DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS whenever normalizedLocalOverride is
truthy, but that should only happen if the device actually has the necessary
local-mode prerequisites (IP, localKey, and protocol). Change the poll_frequency
assignment (the ternary that uses normalizedLocalOverride) so it only returns
EVERY_10_SECONDS when normalizedLocalOverride is true AND the device info
includes ip, localKey, and protocol (e.g., check the same properties used
elsewhere in this module for local connection); otherwise fall back to
EVERY_30_SECONDS. Ensure you reference normalizedLocalOverride, poll_frequency,
and DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS when making the condition change.

Comment on lines +60 to +63
const response = await connector.request({
method: 'GET',
path: `${API.PUBLIC_VERSION_1_0}/users/${appAccountId}/devices`,
query: {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Encode appAccountId before interpolating it into the API path.

Using raw user-provided UID in the URL path can break requests for values containing reserved characters or leading/trailing whitespace.

🔧 Proposed fix
 const validateAppAccount = async (connector, appAccountId) => {
@@
+  const encodedAppAccountId = encodeURIComponent(String(appAccountId).trim());
   const response = await connector.request({
     method: 'GET',
-    path: `${API.PUBLIC_VERSION_1_0}/users/${appAccountId}/devices`,
+    path: `${API.PUBLIC_VERSION_1_0}/users/${encodedAppAccountId}/devices`,
     query: {
       page_no: 1,
       page_size: 1,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/tuya.connect.js` around lines 60 - 63, The API path
is interpolating raw appAccountId which can contain spaces or reserved
characters; before building the path in the connector.request call (the path
using API.PUBLIC_VERSION_1_0 and users/${appAccountId}/devices), trim and
URI-encode the appAccountId (e.g., apply .trim() and encodeURIComponent) and use
that encoded value in the interpolated path so requests are safe for all valid
UIDs.

Comment on lines +73 to +79
const emitFeatureState = (gladys, deviceFeature, transformedValue, previousValue, previousValueChangedAt) => {
if (transformedValue === null || transformedValue === undefined) {
return { emitted: false, changed: false };
}

const changed = previousValue !== transformedValue;
let emitted = changed;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Examine emitFeatureState guard logic (lines 73-97) ==="
sed -n '73,97p' server/services/tuya/lib/tuya.poll.js | cat -n

echo ""
echo "=== Check how transformedValue arrives at emitFeatureState ==="
rg -n "emitFeatureState\(" server/services/tuya/lib/tuya.poll.js -A 1 -B 1

echo ""
echo "=== Verify scaleValue NaN behavior in deviceMapping ==="
sed -n '19,26p' server/services/tuya/lib/device/tuya.deviceMapping.js | cat -n

echo ""
echo "=== Check test for NaN handling in switch power ==="
rg -n "switch power invalid value returns NaN" server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js -A 10 -B 2

echo ""
echo "=== Check device.saveState behavior with NaN ==="
rg -n "should not save NaN as state" server/test/lib/device/device.saveState.test.js -A 15 -B 2

Repository: GladysAssistant/Gladys

Length of output: 3319


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Complete device.saveState NaN test ==="
rg -n "should not save NaN as state" server/test/lib/device/device.saveState.test.js -A 30

echo ""
echo "=== Check if NaN values are filtered elsewhere in tuya.poll.js ==="
rg -n "Number\.isNaN|NaN" server/services/tuya/lib/tuya.poll.js

echo ""
echo "=== Verify transformedValue origin and transformation ==="
sed -n '150,170p' server/services/tuya/lib/tuya.poll.js | cat -n

echo ""
echo "=== Check if Object.is is used anywhere for NaN handling in codebase ==="
rg -n "Object\.is" server/services/tuya/ --type js

Repository: GladysAssistant/Gladys

Length of output: 2130


Use Object.is() for value comparison to handle NaN equality correctly.

On Line 78, previousValue !== transformedValue will always be true for NaN values (since NaN !== NaN), causing the state to be marked as changed and re-emitted on every poll cycle. This wastes event bandwidth for invalid numeric reads.

Replace the comparison with Object.is(previousValue, transformedValue), which correctly treats NaN === NaN as equal. The existing guard at line 74-76 correctly preserves NaN values to keep invalid payloads visibly invalid; downstream validation in device.saveState() already rejects NaN from persistence, so filtering here is unnecessary and would hide data quality issues.

🔧 Proposed fix
   const changed = !Object.is(previousValue, transformedValue);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const emitFeatureState = (gladys, deviceFeature, transformedValue, previousValue, previousValueChangedAt) => {
if (transformedValue === null || transformedValue === undefined) {
return { emitted: false, changed: false };
}
const changed = previousValue !== transformedValue;
let emitted = changed;
const emitFeatureState = (gladys, deviceFeature, transformedValue, previousValue, previousValueChangedAt) => {
if (transformedValue === null || transformedValue === undefined) {
return { emitted: false, changed: false };
}
const changed = !Object.is(previousValue, transformedValue);
let emitted = changed;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/tuya.poll.js` around lines 73 - 79, The equality
check in emitFeatureState currently uses previousValue !== transformedValue
which misclassifies NaN as changed; update the comparison to use
Object.is(previousValue, transformedValue) (invert the logic to set changed =
!Object.is(...)) so NaN is treated as equal to NaN and transient NaN reads
aren’t repeatedly emitted while keeping the existing null/undefined guard and
preserving downstream validation in device.saveState().

Comment on lines +2 to +5
if (value === true || value === 1 || value === '1') {
return true;
}
return typeof value === 'string' && ['true', 'on'].includes(value.trim().toLowerCase());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle trimmed numeric-string truthy values consistently.

At Line 5, strings are trimmed before checking 'true'/'on', but ' 1 ' still resolves to false. This creates an inconsistent normalization path for boolean-like string input.

Proposed fix
 const normalizeBoolean = (value) => {
-  if (value === true || value === 1 || value === '1') {
+  if (value === true || value === 1) {
     return true;
   }
-  return typeof value === 'string' && ['true', 'on'].includes(value.trim().toLowerCase());
+  if (typeof value === 'string') {
+    const normalized = value.trim().toLowerCase();
+    return ['1', 'true', 'on'].includes(normalized);
+  }
+  return false;
 };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (value === true || value === 1 || value === '1') {
return true;
}
return typeof value === 'string' && ['true', 'on'].includes(value.trim().toLowerCase());
const normalizeBoolean = (value) => {
if (value === true || value === 1) {
return true;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return ['1', 'true', 'on'].includes(normalized);
}
return false;
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/utils/tuya.normalize.js` around lines 2 - 5, The
current normalization checks trimmed lowercased strings for 'true'/'on' but
doesn't handle trimmed numeric-strings like ' 1 '; update the normalization in
tuya.normalize.js to first compute a trimmed string (e.g., const s = typeof
value === 'string' ? value.trim() : value), then check truthiness by: return
value === true || value === 1 || s === '1' || (typeof s === 'string' &&
['true','on'].includes(s.toLowerCase())); this ensures ' 1 ' and other trimmed
numeric-strings are treated as true while preserving existing boolean/number
handling.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
server/services/tuya/lib/device/tuya.convertDevice.js (1)

153-155: ⚠️ Potential issue | 🟠 Major

Avoid forcing 10s polling when local prerequisites are incomplete.

LOCAL_OVERRIDE alone is not enough for local mode; polling at 10s should only be enabled when ip, localKey, and protocolVersion are also present.

🔧 Proposed fix
   const online = tuyaDevice.online !== undefined ? tuyaDevice.online : tuyaDevice.is_online;
   const normalizedLocalOverride = normalizeBoolean(localOverride);
+  const hasCompleteLocalConfig = Boolean(ip && localKey && protocolVersion);
@@
-    poll_frequency: normalizedLocalOverride
+    poll_frequency: normalizedLocalOverride && hasCompleteLocalConfig
       ? DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS
       : DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/device/tuya.convertDevice.js` around lines 153 -
155, The poll_frequency currently uses normalizedLocalOverride alone to pick
DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS; change the condition so 10s polling is
only selected when normalizedLocalOverride is true AND the device has local
prerequisites (ip, localKey, and protocolVersion present). Update the
poll_frequency ternary in tuya.convertDevice.js to check all four:
normalizedLocalOverride && device.ip && device.localKey &&
device.protocolVersion (or their local variable equivalents) before choosing
EVERY_10_SECONDS, otherwise use EVERY_30_SECONDS.
server/services/tuya/lib/tuya.poll.js (1)

79-79: ⚠️ Potential issue | 🟠 Major

Use Object.is for change detection to handle NaN correctly.

Current comparison treats NaN as always changed, which can repeatedly emit identical invalid states on each poll cycle.

🔧 Proposed fix
-  const changed = previousValue !== transformedValue;
+  const changed = !Object.is(previousValue, transformedValue);
#!/bin/bash
set -euo pipefail

echo "=== Current change-detection line ==="
rg -nP --type=js -C2 'const\s+changed\s*=' server/services/tuya/lib/tuya.poll.js

echo
echo "=== Existing NaN behavior tests referenced by Tuya mapping/state persistence ==="
rg -nP --type=js -C2 'switch power invalid value returns NaN|should not save NaN as state' \
  server/test/services/tuya/lib/device/feature/tuya.deviceMapping.test.js \
  server/test/lib/device/device.saveState.test.js

Based on learnings: In server/services/tuya/lib/device/tuya.deviceMapping.js, invalid numeric payloads intentionally surface as NaN, and downstream persistence explicitly blocks saving NaN.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/tuya.poll.js` at line 79, The current change
detection uses a strict inequality (const changed = previousValue !==
transformedValue) which treats NaN as always changed; update the comparison in
tuya.poll.js to use Object.is(previousValue, transformedValue) and set changed
to the negation of that (i.e., changed = !Object.is(previousValue,
transformedValue)) so NaN vs NaN is considered equal and identical invalid
states are not repeatedly emitted; ensure you update the expression where
changed is declared and used so downstream logic that relies on the changed flag
(previousValue, transformedValue) continues to work.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@server/services/tuya/lib/device/tuya.convertDevice.js`:
- Around line 153-155: The poll_frequency currently uses normalizedLocalOverride
alone to pick DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS; change the condition so
10s polling is only selected when normalizedLocalOverride is true AND the device
has local prerequisites (ip, localKey, and protocolVersion present). Update the
poll_frequency ternary in tuya.convertDevice.js to check all four:
normalizedLocalOverride && device.ip && device.localKey &&
device.protocolVersion (or their local variable equivalents) before choosing
EVERY_10_SECONDS, otherwise use EVERY_30_SECONDS.

In `@server/services/tuya/lib/tuya.poll.js`:
- Line 79: The current change detection uses a strict inequality (const changed
= previousValue !== transformedValue) which treats NaN as always changed; update
the comparison in tuya.poll.js to use Object.is(previousValue, transformedValue)
and set changed to the negation of that (i.e., changed =
!Object.is(previousValue, transformedValue)) so NaN vs NaN is considered equal
and identical invalid states are not repeatedly emitted; ensure you update the
expression where changed is declared and used so downstream logic that relies on
the changed flag (previousValue, transformedValue) continues to work.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3171274 and e5269db.

📒 Files selected for processing (11)
  • server/services/tuya/lib/device/tuya.convertDevice.js
  • server/services/tuya/lib/tuya.poll.js
  • server/services/tuya/lib/utils/tuya.cloudStrategy.js
  • server/services/tuya/lib/utils/tuya.constants.js
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/cloud-status.json
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-device.json
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/poll-device.json
  • server/test/services/tuya/fixtures/devices/smart-socket-basic/expected-device.json
  • server/test/services/tuya/lib/device/tuya.convertDevice.test.js
  • server/test/services/tuya/lib/tuya.discoverDevices.test.js
  • server/test/services/tuya/lib/tuya.poll.test.js
✅ Files skipped from review due to trivial changes (1)
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/cloud-status.json
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/test/services/tuya/lib/device/tuya.convertDevice.test.js
  • server/test/services/tuya/fixtures/devices/smart-meter-bbcg1hrkrj5rifsd/expected-device.json

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@server/services/tuya/lib/tuya.localPoll.js`:
- Around line 167-168: The code currently unconditionally assigns
device.protocol_version and device.ip from the incoming payload, which can erase
existing values when the payload is partial; change the assignments to only set
these fields when the incoming values are present (e.g., check protocolVersion
!== undefined / ip !== undefined or use nullish checks) so existing
device.protocol_version and device.ip are preserved when the payload omits them.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 766d717 and 547f05c.

📒 Files selected for processing (2)
  • server/services/tuya/lib/tuya.localPoll.js
  • server/test/services/tuya/lib/tuya.localPoll.test.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • server/test/services/tuya/lib/tuya.localPoll.test.js

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

♻️ Duplicate comments (3)
front/src/config/i18n/en.json (1)

1198-1200: ⚠️ Potential issue | 🟡 Minor

Keep the cloud success messages idiomatic in English.

"with success" still reads awkwardly in UI copy here. successfully is the natural phrasing.

✏️ Proposed text fix
-        "connectedAfterSave": "Connected to the Tuya cloud account with success!",
+        "connectedAfterSave": "Connected to the Tuya cloud account successfully!",
...
-        "disconnectSuccess": "Disconnected from the Tuya cloud account with success! Local service still works (it will not auto-reconnect when Gladys restarts).",
+        "disconnectSuccess": "Disconnected from the Tuya cloud account successfully! Local service still works (it will not auto-reconnect when Gladys restarts).",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/config/i18n/en.json` around lines 1198 - 1200, The UI copy uses
"with success" which is non-idiomatic; update the Tuya i18n entries
connectedAfterSave and disconnectSuccess to use "successfully" instead of "with
success" (e.g., change "Connected to the Tuya cloud account with success!" and
"Disconnected from the Tuya cloud account with success! Local service still
works…" to use "successfully"), leaving connectedStatus unchanged.
server/services/tuya/lib/device/tuya.convertDevice.js (1)

179-181: ⚠️ Potential issue | 🟠 Major

Keep 10s polling behind complete local config.

local_override alone does not mean local polling can actually work. Without local_key, ip, and protocol_version, this still pushes the device to 10-second polling while it can only fall back to cloud.

🔧 Suggested fix
-    poll_frequency: normalizedLocalOverride
+    poll_frequency: normalizedLocalOverride && Boolean(localKey && ip && protocolVersion)
       ? DEVICE_POLL_FREQUENCIES.EVERY_10_SECONDS
       : DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/device/tuya.convertDevice.js` around lines 179 -
181, The poll_frequency logic currently uses normalizedLocalOverride to decide
10s polling but must require a full working local config; change the condition
that sets poll_frequency to EVERY_10_SECONDS so it only returns 10s when
normalizedLocalOverride is true AND the device has local_key, ip, and
protocol_version present/valid (check the same fields used elsewhere in
tuya.device handling), otherwise fall back to
DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS; update the condition around
poll_frequency (referencing normalizedLocalOverride, DEVICE_POLL_FREQUENCIES,
and the device properties local_key, ip, protocol_version) accordingly.
front/src/config/i18n/de.json (1)

1290-1291: ⚠️ Potential issue | 🟡 Minor

Use “App Account UID” consistently here.

These two strings still say “User ID”, while the surrounding Tuya setup copy and errorInvalidAppAccountUid use UID. Keeping the label and placeholder aligned avoids user confusion.

✏️ Suggested wording
-        "appAccountId": "App Account User ID",
-        "appAccountIdPlaceholder": "Tuya App User ID eingeben",
+        "appAccountId": "App Account UID",
+        "appAccountIdPlaceholder": "Tuya App Account UID eingeben",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/config/i18n/de.json` around lines 1290 - 1291, The label and
placeholder values for the Tuya app account are inconsistent (they use "User ID"
while other keys use "UID"); update the JSON entries "appAccountId" and
"appAccountIdPlaceholder" to replace "User ID" with "UID" so the label reads
"App Account UID" and the placeholder uses the same UID terminology (and adjust
language/wording to match surrounding strings if needed).
🧹 Nitpick comments (2)
server/test/services/tuya/lib/tuya.localPoll.test.js (1)

488-503: Assert the stateManager.get contract instead of returning blindly.

This stub returns a device for any call, so the test still passes if updateDiscoveredDeviceAfterLocalPoll() queries the wrong store key or forgets the external id. Tighten it with calledOnceWithExactly('deviceByExternalId', 'tuya:device1') or a callsFake assertion so the test actually covers the production integration point.

Suggested patch
       gladys: {
         stateManager: {
-          get: sinon.stub().returns({
-            external_id: 'tuya:device1',
-            params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: '1' }],
-            features: [],
-          }),
+          get: sinon.stub().callsFake((key, externalId) => {
+            expect(key).to.equal('deviceByExternalId');
+            expect(externalId).to.equal('tuya:device1');
+            return {
+              external_id: 'tuya:device1',
+              params: [{ name: DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: '1' }],
+              features: [],
+            };
+          }),
         },
       },
@@
     expect(updated).to.have.property('updatable');
     expect(updated.local_override).to.equal(true);
+    expect(tuyaManager.gladys.stateManager.get.calledOnce).to.equal(true);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/test/services/tuya/lib/tuya.localPoll.test.js` around lines 488 - 503,
The stateManager.get stub in the test is too permissive; tighten it so the test
asserts the correct store key is used by updateDiscoveredDeviceAfterLocalPoll.
Replace the current sinon.stub().returns(...) with either a stub that asserts
its call like
sinon.stub().calledOnceWithExactly('deviceByExternalId','tuya:device1') after
invoking updateDiscoveredDeviceAfterLocalPoll, or use
sinon.stub().callsFake((key, id) => {
expect(key).to.equal('deviceByExternalId'); expect(id).to.equal('tuya:device1');
return { external_id: 'tuya:device1', params: [{ name:
DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: '1' }], features: [] }; }); and keep
the rest of the test unchanged to ensure the production lookup contract is
validated.
front/src/config/i18n/fr.json (1)

1398-1431: Standardize UID vs User ID across the Tuya setup copy.

These adjacent strings appear to describe the same field, but the wording alternates between App account UID and App account User ID. Pick one term and use it everywhere in this block to avoid copy/paste mistakes during setup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/config/i18n/fr.json` around lines 1398 - 1431, The French copy
mixes "App account UID" and "App account User ID" for the same field;
standardize to a single term (pick "App account UID") across this block by
updating the strings referenced by descriptionGetAppAccountUid,
descriptionGetAppAccountUid2, appAccountId, appAccountIdPlaceholder and
errorInvalidAppAccountUid so they consistently use "App account UID" (and adjust
any French wording around it if needed).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@front/src/config/i18n/fr.json`:
- Line 1357: Revert the value of the JSON key
integration.tuya.device.localPollRequired to the established French wording
"Lancez une lecture locale des DP..." (replace the current longer sentence) and,
if you want to keep the extra guidance, move that additional text into the
integration.tuya.device.localPollHelp entry instead so the standardized message
remains unchanged.

In `@front/src/routes/integration/all/tuya/commons/deviceHelpers.js`:
- Around line 138-151: The code currently marks every code from
device.thing_model.services and device.properties as known by calling
addKnownCode unconditionally; change both loops to only call addKnownCode when
the code corresponds to a mapped/supported feature (i.e., run a predicate like
hasMappedFeature(code) or lookup in the existing feature map before calling
addKnownCode). Update the service.properties iteration and the
propertiesPayload/properties iteration to fetch property.code into a local var
and only invoke addKnownCode(code) if the mapping lookup (e.g., featureMap[code]
or getFeatureForCode(code)) returns a valid mapping.

In `@front/src/routes/integration/all/tuya/discover-page/githubIssue.js`:
- Around line 304-311: The search query builder currently wraps raw title in
quotes in buildGithubSearchQuery (and is used by buildGithubSearchUrl and
buildGithubSearchApiUrl) without escaping internal quotes; update
buildGithubSearchQuery to escape any double quotes (and optionally backslashes)
in the title before surrounding it with quotes so phrase searches remain valid
(e.g., replace " with \" in the title), leaving the rest of the callers
(buildGithubSearchUrl/buildGithubSearchApiUrl) unchanged so encodeURIComponent
still runs on the final query string.

In
`@front/src/routes/integration/all/tuya/discover-page/TuyaGithubIssueSection.jsx`:
- Around line 31-36: The component is vulnerable to stale async completions
repopulating state or navigating for a previous device; in
TuyaGithubIssueSection.jsx (componentWillReceiveProps and the async flows that
call checkGithubIssues and perform setState/navigation) capture the current
device.external_id into a local const at the start of each async flow (e.g.,
inside the methods that call await checkGithubIssues(...), and inside any
handlers that eventually call setState or navigation), and before every
post-await setState or navigation compare this.props.device?.external_id (or the
latest prop) to the captured external_id and bail if they differ; apply the same
guard to the other async blocks referenced (around lines 75-95, 103-149,
236-260) so stale responses are ignored.
- Around line 62-69: The click handler currently opens a popup (const popup =
window.open('about:blank', '_blank')) and then awaits checkGithubIssues(...)
before doing the fallback window.open(issueUrl, '_blank'), which loses the user
gesture if the initial open was blocked; change the flow so that if popup is
null you immediately attempt the fallback open(issueUrl, '_blank') synchronously
(still within the click handler) before any awaits, and only use the opened
window (popup) for later document updates after await; apply the same change for
the other occurrence around the second checkGithubIssues call (the block
involving the popup variable and the fallback at lines ~236-259) so both
fallbacks run inside the original user gesture.

In `@front/src/routes/integration/all/tuya/TuyaLocalPollSection.jsx`:
- Around line 136-143: The current use of "||" when computing protocolVersion,
ipAddress, cloudIp, pollProtocolLabel (and similar logic in commons/localPoll.js
and TuyaDeviceBox.jsx) collapses explicit empty strings to persisted values,
preventing users from clearing fields to trigger the "empty for auto-detect"
flow; update these computations to distinguish unset (undefined/null) from an
explicit empty string by checking for null/undefined (e.g., using ?? or explicit
!== undefined/null checks) so cleared values remain '' end-to-end, and propagate
the same change to commons/localPoll.js and TuyaDeviceBox.jsx to ensure polling
and UI respect an explicit empty value.

In `@server/services/tuya/lib/device/tuya.convertDevice.js`:
- Around line 135-147: currentProperties is only populated when properties is an
object with a properties array, so bare-array shadow payloads are ignored;
change the initialization of currentProperties to accept either a plain array or
the wrapped form (i.e., treat properties as the array when
Array.isArray(properties) is true, otherwise fall back to
Array.isArray(properties && properties.properties) ? properties.properties :
[]), leaving the rest of the loop (groups, property.code,
property.custom_name/name) unchanged.

In `@server/services/tuya/lib/tuya.localPoll.js`:
- Around line 222-239: mergeDevices overwrites device.params with
newDevice.params causing saved Gladys-only params to be lost; before calling
normalizeExistingDevice/mergeDevices, retrieve the existing device params via
tuyaManager.gladys.stateManager.get('deviceByExternalId', device.external_id)
(as done with normalizeExistingDevice), then merge those existing params into
the local device.params (preserving existing entries keyed by param name) —
e.g., collect existing.params, iterate and upsert any entries not present in
device.params using the same upsertParam helper (or dedupe by DEVICE_PARAM_NAME
keys) so CLOUD_READ_STRATEGY and other persisted params are retained, then call
normalizeExistingDevice and mergeDevices as before.

---

Duplicate comments:
In `@front/src/config/i18n/de.json`:
- Around line 1290-1291: The label and placeholder values for the Tuya app
account are inconsistent (they use "User ID" while other keys use "UID"); update
the JSON entries "appAccountId" and "appAccountIdPlaceholder" to replace "User
ID" with "UID" so the label reads "App Account UID" and the placeholder uses the
same UID terminology (and adjust language/wording to match surrounding strings
if needed).

In `@front/src/config/i18n/en.json`:
- Around line 1198-1200: The UI copy uses "with success" which is non-idiomatic;
update the Tuya i18n entries connectedAfterSave and disconnectSuccess to use
"successfully" instead of "with success" (e.g., change "Connected to the Tuya
cloud account with success!" and "Disconnected from the Tuya cloud account with
success! Local service still works…" to use "successfully"), leaving
connectedStatus unchanged.

In `@server/services/tuya/lib/device/tuya.convertDevice.js`:
- Around line 179-181: The poll_frequency logic currently uses
normalizedLocalOverride to decide 10s polling but must require a full working
local config; change the condition that sets poll_frequency to EVERY_10_SECONDS
so it only returns 10s when normalizedLocalOverride is true AND the device has
local_key, ip, and protocol_version present/valid (check the same fields used
elsewhere in tuya.device handling), otherwise fall back to
DEVICE_POLL_FREQUENCIES.EVERY_30_SECONDS; update the condition around
poll_frequency (referencing normalizedLocalOverride, DEVICE_POLL_FREQUENCIES,
and the device properties local_key, ip, protocol_version) accordingly.

---

Nitpick comments:
In `@front/src/config/i18n/fr.json`:
- Around line 1398-1431: The French copy mixes "App account UID" and "App
account User ID" for the same field; standardize to a single term (pick "App
account UID") across this block by updating the strings referenced by
descriptionGetAppAccountUid, descriptionGetAppAccountUid2, appAccountId,
appAccountIdPlaceholder and errorInvalidAppAccountUid so they consistently use
"App account UID" (and adjust any French wording around it if needed).

In `@server/test/services/tuya/lib/tuya.localPoll.test.js`:
- Around line 488-503: The stateManager.get stub in the test is too permissive;
tighten it so the test asserts the correct store key is used by
updateDiscoveredDeviceAfterLocalPoll. Replace the current
sinon.stub().returns(...) with either a stub that asserts its call like
sinon.stub().calledOnceWithExactly('deviceByExternalId','tuya:device1') after
invoking updateDiscoveredDeviceAfterLocalPoll, or use
sinon.stub().callsFake((key, id) => {
expect(key).to.equal('deviceByExternalId'); expect(id).to.equal('tuya:device1');
return { external_id: 'tuya:device1', params: [{ name:
DEVICE_PARAM_NAME.LOCAL_OVERRIDE, value: '1' }], features: [] }; }); and keep
the rest of the test unchanged to ensure the production lookup contract is
validated.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 082f5fad-a336-4dd0-a0c6-be824bc30fad

📥 Commits

Reviewing files that changed from the base of the PR and between 547f05c and e486f97.

⛔ Files ignored due to path filters (1)
  • server/services/tuya/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (16)
  • front/src/config/i18n/de.json
  • front/src/config/i18n/en.json
  • front/src/config/i18n/fr.json
  • front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx
  • front/src/routes/integration/all/tuya/TuyaLocalPollSection.jsx
  • front/src/routes/integration/all/tuya/commons/deviceHelpers.js
  • front/src/routes/integration/all/tuya/commons/localPoll.js
  • front/src/routes/integration/all/tuya/discover-page/TuyaGithubIssueSection.jsx
  • front/src/routes/integration/all/tuya/discover-page/githubIssue.js
  • server/services/tuya/lib/device/tuya.convertDevice.js
  • server/services/tuya/lib/mappings/index.js
  • server/services/tuya/lib/tuya.localPoll.js
  • server/services/tuya/package.json
  • server/test/services/tuya/lib/device/tuya.convertDevice.test.js
  • server/test/services/tuya/lib/mappings/index.test.js
  • server/test/services/tuya/lib/tuya.localPoll.test.js
🚧 Files skipped from review as they are similar to previous changes (2)
  • server/services/tuya/package.json
  • server/test/services/tuya/lib/device/tuya.convertDevice.test.js

"localPollButton": "Lecture locale des DP",
"localPollInProgress": "Lecture locale en cours... protocole {{protocol}}.",
"localPollHelp": "Si vous connaissez le protocole, sélectionnez-le puis cliquez pour vérifier. Sinon, cliquez directement pour une analyse complète (plus longue).",
"localPollRequired": "Le mode local est activé et les paramètres locaux ont changé. Lancez une lecture locale des DP avec succès avant de sauvegarder. Si vous n'arrivez pas à faire fonctionner le local, repassez en mode cloud.",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Keep localPollRequired on the established FR wording.

This key was previously standardized, and the longer warning here drifts from the agreed FR copy. If you still want the extra guidance, localPollHelp is the better place for it.

✏️ Suggested copy
-        "localPollRequired": "Le mode local est activé et les paramètres locaux ont changé. Lancez une lecture locale des DP avec succès avant de sauvegarder. Si vous n'arrivez pas à faire fonctionner le local, repassez en mode cloud.",
+        "localPollRequired": "Lancez une lecture locale des DP..."

Based on learnings: in front/src/config/i18n/fr.json, integration.tuya.device.localPollRequired should keep the standard wording Lancez une lecture locale des DP....

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"localPollRequired": "Le mode local est activé et les paramètres locaux ont changé. Lancez une lecture locale des DP avec succès avant de sauvegarder. Si vous n'arrivez pas à faire fonctionner le local, repassez en mode cloud.",
"localPollRequired": "Lancez une lecture locale des DP...",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/config/i18n/fr.json` at line 1357, Revert the value of the JSON key
integration.tuya.device.localPollRequired to the established French wording
"Lancez une lecture locale des DP..." (replace the current longer sentence) and,
if you want to keep the extra guidance, move that additional text into the
integration.tuya.device.localPollHelp entry instead so the standardized message
remains unchanged.

Comment on lines +138 to +151
const services = Array.isArray(device && device.thing_model && device.thing_model.services)
? device.thing_model.services
: [];
services.forEach(service => {
const properties = Array.isArray(service && service.properties) ? service.properties : [];
properties.forEach(property => addKnownCode(property && property.code));
});
const propertiesPayload = device && device.properties;
const properties = Array.isArray(propertiesPayload)
? propertiesPayload
: Array.isArray(propertiesPayload && propertiesPayload.properties)
? propertiesPayload.properties
: [];
properties.forEach(property => addKnownCode(property && property.code));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Only mapped features should mark a specification code as known.

Adding thing_model and properties codes to knownCodes makes unknown_specification_codes disappear for unsupported devices, because every code already present in the raw cloud payload is treated as “known” before the comparison. That hides exactly the codes maintainers need to map when opening support issues.

🧰 Tools
🪛 Biome (2.4.4)

[error] 143-143: This callback passed to forEach() iterable method should not return a value.

(lint/suspicious/useIterableCallbackReturn)


[error] 151-151: This callback passed to forEach() iterable method should not return a value.

(lint/suspicious/useIterableCallbackReturn)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/routes/integration/all/tuya/commons/deviceHelpers.js` around lines
138 - 151, The code currently marks every code from device.thing_model.services
and device.properties as known by calling addKnownCode unconditionally; change
both loops to only call addKnownCode when the code corresponds to a
mapped/supported feature (i.e., run a predicate like hasMappedFeature(code) or
lookup in the existing feature map before calling addKnownCode). Update the
service.properties iteration and the propertiesPayload/properties iteration to
fetch property.code into a local var and only invoke addKnownCode(code) if the
mapping lookup (e.g., featureMap[code] or getFeatureForCode(code)) returns a
valid mapping.

Comment on lines +304 to +311
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`;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cat -n front/src/routes/integration/all/tuya/discover-page/githubIssue.js | head -350 | tail -100

Repository: GladysAssistant/Gladys

Length of output: 5018


🏁 Script executed:

# Check where device.model comes from and if there's any validation
grep -r "device\.model" front/src/routes/integration/all/tuya --include="*.js" | head -20

Repository: GladysAssistant/Gladys

Length of output: 1190


🏁 Script executed:

node << 'EOF'
// Test what encodeURIComponent does with quotes in search query
const buildGithubSearchQuery = title => `repo:GladysAssistant/Gladys in:title "${title}"`;
const testTitle = 'LSC Plug "FR"';
const query = buildGithubSearchQuery(testTitle);
console.log("Query:", query);
console.log("encodeURIComponent result:", encodeURIComponent(query));
console.log("Decoded:", decodeURIComponent(encodeURIComponent(query)));
EOF

Repository: GladysAssistant/Gladys

Length of output: 289


🏁 Script executed:

node << 'EOF'
// Test GitHub API query escaping behavior
const scenarios = [
  {
    name: "Current (unescaped)",
    query: 'repo:GladysAssistant/Gladys in:title "LSC Plug "FR""'
  },
  {
    name: "Escaped backslash",
    query: 'repo:GladysAssistant/Gladys in:title "LSC Plug \\"FR\\""'
  },
  {
    name: "Without inner quotes",
    query: 'repo:GladysAssistant/Gladys in:title LSC Plug FR'
  }
];

console.log("GitHub API search query escaping analysis:");
console.log("=" .repeat(60));
scenarios.forEach(s => {
  const encoded = encodeURIComponent(s.query);
  console.log(`\n${s.name}:`);
  console.log(`  Query: ${s.query}`);
  console.log(`  Encoded: ${encoded}`);
  console.log(`  URL would be: https://api.github.com/search/issues?q=${encoded}`);
});

// Show the specific problem
console.log("\n" + "=".repeat(60));
console.log("\nProblem analysis:");
console.log("In GitHub's quoted phrase search syntax:");
console.log('  "text with "quotes"" - the inner quotes terminate the phrase');
console.log("  This makes the query malformed and may cause:");
console.log("  - Different search interpretation");
console.log("  - Query syntax errors");
console.log("  - Missing results for duplicate detection");
EOF

Repository: GladysAssistant/Gladys

Length of output: 1391


🏁 Script executed:

# Search for escaping functions or quote handling
grep -r "escape\|sanitize" front/src/routes/integration/all/tuya/discover-page/ --include="*.js" | grep -v node_modules

Repository: GladysAssistant/Gladys

Length of output: 1050


🏁 Script executed:

grep -n "const escapeJsString\|const sanitizeIssueValue\|const sanitizeParams" front/src/routes/integration/all/tuya/discover-page/githubIssue.js -A 10

Repository: GladysAssistant/Gladys

Length of output: 1263


🏁 Script executed:

# Search for test data or examples of device models
grep -r "product_name\|device.*model\|thing_model" front/src/routes/integration/all/tuya --include="*.js" | grep -E "(test|fixture|example|mock|=\s*['\"])" | head -20

Repository: GladysAssistant/Gladys

Length of output: 48


🏁 Script executed:

node << 'EOF'
// Test to understand GitHub API phrase search escaping
// GitHub API documentation states that quoted phrase searches should escape inner quotes with backslash

console.log("GitHub Search API - Phrase Search Escaping Rules:");
console.log("=" .repeat(70));
console.log("\nWhen searching for a phrase in GitHub:");
console.log("- Use double quotes to search for exact phrase: \"hello world\"");
console.log("- Backslash escapes special characters inside quotes");
console.log("- Example: to find 'test\"case', use: \"test\\\"case\"");
console.log("\nThe key issue:");
console.log("- Without escaping: in:title \"Plug \"FR\"\"");
console.log("  GitHub interprets this as: search in title for 'Plug' then literal text '\"FR\"'");
console.log("- With escaping: in:title \"Plug \\\"FR\\\"\"");
console.log("  GitHub interprets this correctly as: search phrase with quotes");

console.log("\n" + "=".repeat(70));
console.log("Conclusion: The unescaped version WILL cause incorrect search results");
console.log("because the inner quotes will break the phrase boundary.");
EOF

Repository: GladysAssistant/Gladys

Length of output: 833


🏁 Script executed:

# Search for any examples or documentation about Tuya device names
find front/src/routes/integration/all/tuya -type f -name "*.md" -o -name "*.txt" | xargs cat 2>/dev/null | head -50

Repository: GladysAssistant/Gladys

Length of output: 48


🏁 Script executed:

find . -path ./node_modules -prune -o -name "*.test.js" -o -name "*.spec.js" | xargs grep -l "tuya.*model\|device.*model" 2>/dev/null | head -5

Repository: GladysAssistant/Gladys

Length of output: 366


🏁 Script executed:

node << 'EOF'
// Simulate actual GitHub API request with malformed query
const baseUrl = "https://api.github.com/search/issues";

// Example with unescaped quotes
const malformedQuery = 'repo:GladysAssistant/Gladys in:title "LSC Plug "FR""';
const encodedMalformed = encodeURIComponent(malformedQuery);
const urlMalformed = `${baseUrl}?q=${encodedMalformed}`;

// Example with escaped quotes  
const escapedQuery = 'repo:GladysAssistant/Gladys in:title "LSC Plug \\"FR\\""';
const encodedEscaped = encodeURIComponent(escapedQuery);
const urlEscaped = `${baseUrl}?q=${encodedEscaped}`;

console.log("When GitHub API decodes these URLs:\n");
console.log("Malformed query URL-decodes to:");
console.log(decodeURIComponent(encodedMalformed));
console.log("\nGitHub interprets this as broken phrase search\n");

console.log("Escaped query URL-decodes to:");
console.log(decodeURIComponent(encodedEscaped));
console.log("\nGitHub interprets this as a valid phrase search\n");

console.log("Result: encodeURIComponent only makes it URL-safe,");
console.log("but doesn't fix the underlying GitHub search syntax issue.");
EOF

Repository: GladysAssistant/Gladys

Length of output: 481


🏁 Script executed:

sed -n '287,312p' front/src/routes/integration/all/tuya/discover-page/githubIssue.js

Repository: GladysAssistant/Gladys

Length of output: 1304


Escape quotes in GitHub search query to prevent malformed phrase searches.

buildGithubSearchQuery() wraps the raw title in double quotes for a phrase search without escaping. If a device model like LSC Plug "FR" is used, the unescaped quotes break the phrase boundary, causing GitHub to misinterpret the search. This results in checkGithubIssues() failing to find duplicates and suggesting duplicate issues.

Suggested patch
+const escapeGithubSearchPhrase = value =>
+  String(value || '')
+    .replace(/\\/g, '\\\\')
+    .replace(/"/g, '\\"')
+    .replace(/\r?\n/g, ' ');
+
-const buildGithubSearchQuery = title => `repo:GladysAssistant/Gladys in:title "${title}"`;
+const buildGithubSearchQuery = title =>
+  `repo:GladysAssistant/Gladys in:title "${escapeGithubSearchPhrase(title)}"`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/routes/integration/all/tuya/discover-page/githubIssue.js` around
lines 304 - 311, The search query builder currently wraps raw title in quotes in
buildGithubSearchQuery (and is used by buildGithubSearchUrl and
buildGithubSearchApiUrl) without escaping internal quotes; update
buildGithubSearchQuery to escape any double quotes (and optionally backslashes)
in the title before surrounding it with quotes so phrase searches remain valid
(e.g., replace " with \" in the title), leaving the rest of the callers
(buildGithubSearchUrl/buildGithubSearchApiUrl) unchanged so encodeURIComponent
still runs on the final query string.

Comment on lines +31 to +36
componentWillReceiveProps(nextProps) {
const currentDevice = this.props.device;
const nextDevice = nextProps.device;
if (!currentDevice || !nextDevice || currentDevice.external_id !== nextDevice.external_id) {
this.setState(buildInitialState());
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Ignore stale async completions after the device changes.

Both flows keep using the captured device after await checkGithubIssues(...). componentWillReceiveProps resets the state for a new external_id, but the older request can still repopulate the component or open an issue for the previous device. Capture the starting external_id and bail before every post-await setState/navigation if this.props.device no longer matches.

Also applies to: 75-95, 103-149, 236-260

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@front/src/routes/integration/all/tuya/discover-page/TuyaGithubIssueSection.jsx`
around lines 31 - 36, The component is vulnerable to stale async completions
repopulating state or navigating for a previous device; in
TuyaGithubIssueSection.jsx (componentWillReceiveProps and the async flows that
call checkGithubIssues and perform setState/navigation) capture the current
device.external_id into a local const at the start of each async flow (e.g.,
inside the methods that call await checkGithubIssues(...), and inside any
handlers that eventually call setState or navigation), and before every
post-await setState or navigation compare this.props.device?.external_id (or the
latest prop) to the captured external_id and bail if they differ; apply the same
guard to the other async blocks referenced (around lines 75-95, 103-149,
236-260) so stale responses are ignored.

Comment on lines +62 to +69
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...';
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find and examine the TuyaGithubIssueSection.jsx file
find . -name "TuyaGithubIssueSection.jsx" -type f

Repository: GladysAssistant/Gladys

Length of output: 146


🏁 Script executed:

# Read the file to understand the popup handling
cat -n front/src/routes/integration/all/tuya/discover-page/TuyaGithubIssueSection.jsx

Repository: GladysAssistant/Gladys

Length of output: 14962


Handle blocked popups before async operations.

When window.open('about:blank', '_blank') is blocked and returns null, the fallback window.open(issueUrl, '_blank') at line 155 (and similar locations) executes only after await checkGithubIssues(...) at line 75. This places the fallback outside the original click gesture context, causing strict popup blockers to drop it again, leaving the user without an issue page.

Also applies to: 236-259 (line 258 fallback occurs after await at line 236).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@front/src/routes/integration/all/tuya/discover-page/TuyaGithubIssueSection.jsx`
around lines 62 - 69, The click handler currently opens a popup (const popup =
window.open('about:blank', '_blank')) and then awaits checkGithubIssues(...)
before doing the fallback window.open(issueUrl, '_blank'), which loses the user
gesture if the initial open was blocked; change the flow so that if popup is
null you immediately attempt the fallback open(issueUrl, '_blank') synchronously
(still within the click handler) before any awaits, and only use the opened
window (popup) for later document updates after await; apply the same change for
the other occurrence around the second checkGithubIssues call (the block
involving the popup variable and the fallback at lines ~236-259) so both
fallbacks run inside the original user gesture.

Comment on lines +136 to +143
const protocolVersion = params.PROTOCOL_VERSION || (device && device.protocol_version) || '';
const ipAddress = params.IP_ADDRESS || (device && device.ip) || '';
const cloudIp = params.CLOUD_IP || (device && device.cloud_ip) || '';
const localOverride = normalizeBoolean(getLocalOverrideValue(device));
const showCloudIp = localOverride !== true;
const displayIp = showCloudIp ? cloudIp : ipAddress;
const canPollLocal = localOverride === true && !!localKey && !!deviceId;
const pollProtocolLabel = localPollProtocol || protocolVersion || '-';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don’t collapse an explicit empty IP/protocol back to the persisted value.

Using || here makes '' indistinguishable from “unset”. On devices that already have device.protocol_version or device.ip, clearing the select/input snaps back to the old value, so users cannot trigger the advertised “leave empty for full scan / auto-detect” flow. The same fallback pattern is also used in front/src/routes/integration/all/tuya/commons/localPoll.js, so polling will still reuse the stale values even if the field was cleared.

🛠️ Suggested direction
   render({ device, deviceIndex, localPollStatus, localPollError, localPollProtocol }) {
     const params = buildParamsMap(device);
+    const hasParam = name => Object.prototype.hasOwnProperty.call(params, name);
     const deviceId = params.DEVICE_ID || getTuyaDeviceId(device);
     const localKey = params.LOCAL_KEY || (device && device.local_key) || '';
-    const protocolVersion = params.PROTOCOL_VERSION || (device && device.protocol_version) || '';
-    const ipAddress = params.IP_ADDRESS || (device && device.ip) || '';
+    const protocolVersion = hasParam('PROTOCOL_VERSION') ? params.PROTOCOL_VERSION : (device && device.protocol_version) || '';
+    const ipAddress = hasParam('IP_ADDRESS') ? params.IP_ADDRESS : (device && device.ip) || '';

Apply the same distinction in commons/localPoll.js and TuyaDeviceBox.jsx so cleared values stay cleared end-to-end.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const protocolVersion = params.PROTOCOL_VERSION || (device && device.protocol_version) || '';
const ipAddress = params.IP_ADDRESS || (device && device.ip) || '';
const cloudIp = params.CLOUD_IP || (device && device.cloud_ip) || '';
const localOverride = normalizeBoolean(getLocalOverrideValue(device));
const showCloudIp = localOverride !== true;
const displayIp = showCloudIp ? cloudIp : ipAddress;
const canPollLocal = localOverride === true && !!localKey && !!deviceId;
const pollProtocolLabel = localPollProtocol || protocolVersion || '-';
const hasParam = name => Object.prototype.hasOwnProperty.call(params, name);
const protocolVersion = hasParam('PROTOCOL_VERSION') ? params.PROTOCOL_VERSION : (device && device.protocol_version) || '';
const ipAddress = hasParam('IP_ADDRESS') ? params.IP_ADDRESS : (device && device.ip) || '';
const cloudIp = params.CLOUD_IP || (device && device.cloud_ip) || '';
const localOverride = normalizeBoolean(getLocalOverrideValue(device));
const showCloudIp = localOverride !== true;
const displayIp = showCloudIp ? cloudIp : ipAddress;
const canPollLocal = localOverride === true && !!localKey && !!deviceId;
const pollProtocolLabel = localPollProtocol || protocolVersion || '-';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/routes/integration/all/tuya/TuyaLocalPollSection.jsx` around lines
136 - 143, The current use of "||" when computing protocolVersion, ipAddress,
cloudIp, pollProtocolLabel (and similar logic in commons/localPoll.js and
TuyaDeviceBox.jsx) collapses explicit empty strings to persisted values,
preventing users from clearing fields to trigger the "empty for auto-detect"
flow; update these computations to distinguish unset (undefined/null) from an
explicit empty string by checking for null/undefined (e.g., using ?? or explicit
!== undefined/null checks) so cleared values remain '' end-to-end, and propagate
the same change to commons/localPoll.js and TuyaDeviceBox.jsx to ensure polling
and UI respect an explicit empty value.

Comment on lines +135 to +147
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,
};
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle array-shaped shadow properties here too.

server/services/tuya/lib/mappings/index.js already accepts properties as either an array or { properties: [...] }, but this block only reads the wrapped form. When the shadow payload arrives as a bare array, type detection still succeeds while currentProperties stays empty, so shadow-only devices won't create any features.

🔧 Suggested fix
-  const currentProperties = Array.isArray(properties && properties.properties) ? properties.properties : [];
+  const currentProperties = Array.isArray(properties)
+    ? properties
+    : Array.isArray(properties && properties.properties)
+      ? properties.properties
+      : [];
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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 currentProperties = Array.isArray(properties)
? properties
: 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,
};
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/device/tuya.convertDevice.js` around lines 135 -
147, currentProperties is only populated when properties is an object with a
properties array, so bare-array shadow payloads are ignored; change the
initialization of currentProperties to accept either a plain array or the
wrapped form (i.e., treat properties as the array when Array.isArray(properties)
is true, otherwise fall back to Array.isArray(properties &&
properties.properties) ? properties.properties : []), leaving the rest of the
loop (groups, property.code, property.custom_name/name) unchanged.

Comment on lines +222 to +239
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);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve saved params before calling mergeDevices.

mergeDevices() replaces params with newDevice.params; it does not deep-merge them. Here device.params is rebuilt from the discovered-device copy only, so any saved params that exist only on the Gladys device get dropped after a local poll. That is especially risky for params like CLOUD_READ_STRATEGY, which this PR now relies on for Thing Model shadow-read behavior.

Suggested patch
   if (tuyaManager.gladys && tuyaManager.gladys.stateManager) {
     const existing = normalizeExistingDevice(
       tuyaManager.gladys.stateManager.get('deviceByExternalId', device.external_id),
     );
+    const existingParams = Array.isArray(existing && existing.params) ? existing.params : [];
+    const currentParamNames = new Set(device.params.map((param) => param.name));
+    device.params = [
+      ...device.params,
+      ...existingParams.filter((param) => !currentParamNames.has(param.name)),
+    ];
     device = mergeDevices(device, existing);
   }

Based on learnings: prefer returning a partial device object with whatever data is available and keep Tuya flows resilient instead of dropping usable data.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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);
}
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),
);
const existingParams = Array.isArray(existing && existing.params) ? existing.params : [];
const currentParamNames = new Set(device.params.map((param) => param.name));
device.params = [
...device.params,
...existingParams.filter((param) => !currentParamNames.has(param.name)),
];
device = mergeDevices(device, existing);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/lib/tuya.localPoll.js` around lines 222 - 239,
mergeDevices overwrites device.params with newDevice.params causing saved
Gladys-only params to be lost; before calling
normalizeExistingDevice/mergeDevices, retrieve the existing device params via
tuyaManager.gladys.stateManager.get('deviceByExternalId', device.external_id)
(as done with normalizeExistingDevice), then merge those existing params into
the local device.params (preserving existing entries keyed by param name) —
e.g., collect existing.params, iterate and upsert any entries not present in
device.params using the same upsertParam helper (or dedupe by DEVICE_PARAM_NAME
keys) so CLOUD_READ_STRATEGY and other persisted params are retained, then call
normalizeExistingDevice and mergeDevices as before.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant