Skip to content

Tuya - local baseline - PR1#2434

Open
Terdious wants to merge 64 commits intoGladysAssistant:masterfrom
Terdious:tuya-local-baseline
Open

Tuya - local baseline - PR1#2434
Terdious wants to merge 64 commits intoGladysAssistant:masterfrom
Terdious:tuya-local-baseline

Conversation

@Terdious
Copy link
Copy Markdown
Contributor

@Terdious Terdious commented Feb 15, 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 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

Scope

  • Translate

    • +193 lines / -52 lines
  • Front

    • +1072 lines / -71 lines
  • Server

    • +1278 lines / -38 lines
  • Server Tests

    • +2315 lines / -37 lines
  • Package

    • +103 lines / -2 lines

Before

imageimage

After

  • Added new fields required for Tuya cloud connection.
  • Client Secret is now masked in UI.
  • Added clearer setup guidance for users.
  • Added local-mode workflow and related user information.
  • Added specific logs when Tuya is not configured.
image
  • Added connection lifecycle logs.
image
  • Added explicit error logs for invalid Client ID / Client Secret cases.
image image
  • Added a manual cloud disconnect button (with logs) so users can stay disconnected once their devices are configured for local usage.
image
  • Added cloud scan to discover devices linked to Tuya/Smart Life apps, with loader/status feedback.
image
  • Added automatic local scan to discover matching LAN devices and auto-fill local parameters (IP address and protocol version), required for local control.
image
  • Reworked discovered devices list (Tasmota-like ordering): new creatable devices first, then already-created devices requiring updates, then unsupported/new devices, then already-created up-to-date devices.
  • Added clearer user instructions for cloud-only and local flows.
  • For cloud usage: one click on Save is enough.
  • For local usage: user guidance from the IP Address field explains what to do when auto local scan cannot find the device (different subnet, etc.).
image
  • To switch an existing cloud device to local mode: click the Cloud/Local toggle in the IP Address field.
  • The field is cleared, and a valid local IP must be entered.
  • Update (or Save from the Devices tab) remains disabled until local DP validation is successful using Poll local DP.
  • If protocol version is unknown, users can run poll without selecting one (all supported versions are tested, slower). If protocol is known, selecting it first is faster.
  • If no protocol works, either the IP is wrong or the device uses an unsupported protocol.
image image image image
  • Once Local poll OK is returned, users can update/save safely.
image

Summary by CodeRabbit

  • New Features

    • Local UDP discovery and TCP local polling with in‑UI poll controls, device details (IDs, keys, IP, protocol), GitHub issue helper, and service status/disconnect endpoints.
  • Improvements

    • Enhanced cloud vs local setup flow and connection state messaging, auto-reconnect, protocol 3.5 handling, UDP port/scan feedback, IP masking, and local-first write fallback.
  • Documentation

    • Expanded EN/DE/FR translations and updated setup labels/placeholders.
  • Tests

    • Extensive new tests for local scan/poll, config, status, device param utilities, and disconnect flows.

…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.
@relativeci
Copy link
Copy Markdown

relativeci bot commented Feb 15, 2026

#4133 Bundle Size — 11.51MiB (+0.62%).

19ff616(current) vs b3cfa5a master#4120(baseline)

Warning

Bundle contains 2 duplicate packages – View duplicate packages

Bundle metrics  Change 3 changes Regression 2 regressions
                 Current
#4133
     Baseline
#4120
Regression  Initial JS 6.49MiB(+1.11%) 6.42MiB
Regression  Initial CSS 310.6KiB(+0.02%) 310.54KiB
Change  Cache Invalidation 58.65% 0%
No change  Chunks 51 51
No change  Assets 179 179
No change  Modules 1643 1643
No change  Duplicate Modules 21 21
No change  Duplicate Code 0.94% 0.94%
No change  Packages 136 136
No change  Duplicate Packages 2 2
Bundle size by type  Change 2 changes Regression 2 regressions
                 Current
#4133
     Baseline
#4120
Regression  JS 8.39MiB (+0.86%) 8.32MiB
No change  IMG 2.68MiB 2.68MiB
Regression  CSS 328.45KiB (+0.02%) 328.39KiB
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-local-baselineProject dashboard


Generated by RelativeCIDocumentationReport issue

@codecov
Copy link
Copy Markdown

codecov bot commented Feb 15, 2026

Codecov Report

❌ Patch coverage is 98.97959% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.81%. Comparing base (b3cfa5a) to head (19ff616).

Files with missing lines Patch % Lines
server/services/tuya/lib/tuya.connect.js 93.18% 3 Missing ⚠️
server/services/tuya/api/tuya.controller.js 90.00% 2 Missing ⚠️
server/services/tuya/lib/tuya.poll.js 90.00% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##           master    #2434    +/-   ##
========================================
  Coverage   98.81%   98.81%            
========================================
  Files        1009     1017     +8     
  Lines       17670    18235   +565     
========================================
+ Hits        17460    18019   +559     
- Misses        210      216     +6     

☔ 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.

…s et ajouter des tests pour la gestion des appareils locaux
@Terdious Terdious changed the title [WIP] Tuya local baseline Tuya local baseline Feb 15, 2026
Terdious and others added 8 commits February 16, 2026 09:14
…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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Feb 20, 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

Adds local UDP discovery and TCP polling for Tuya devices, extensive frontend i18n/UI updates for local/cloud flows, new backend local-scan/local-poll/status/disconnect endpoints, device param normalization and local write paths, auto-reconnect/manual-disconnect handling, and many unit tests.

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 many Tuya translations for device features, discovery, local polling, setup texts (Client ID/Secret, App Username), connection states, and protocol 3.5 notes.
Frontend — Device UI
front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx
Added local-poll workflow, protocol/IP controls, masking/sanitization helpers, GitHub issue builder, local poll status/state, and adjusted enablement/validation for save/update flows.
Frontend — Discovery UI
front/src/routes/integration/all/tuya/discover-page/DiscoverTab.jsx, front/src/routes/integration/all/tuya/discover-page/style.css
Added runLocalScan, UDP scan state (udpScanLoading/udpScanError/udpScanPortErrors), local-scan UI controls/alerts, device ordering and loader styling.
Frontend — Setup UI
front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx
Refactored to track Tuya connection state, websocket listeners, get/save/status flows, appUsername and secret visibility, disconnect flow; export now connected with session.
Backend — Controller & API
server/services/tuya/api/tuya.controller.js
Added endpoints: local-poll, local-scan, status, disconnect; merge/upsert helpers integrate local scan/poll results into discovered devices and device params.
Backend — Handler Core & Lifecycle
server/services/tuya/index.js, server/services/tuya/lib/index.js, server/services/tuya/lib/tuya.init.js
Registered localScan/localPoll/getStatus/manualDisconnect methods, added lastError, auto-reconnect loop (30min), and init respects MANUAL_DISCONNECT (early return/emits status).
Backend — Connection & Disconnect
server/services/tuya/lib/tuya.connect.js, server/services/tuya/lib/tuya.disconnect.js
Await connector init, persist/clear MANUAL_DISCONNECT on connect/save, capture lastError, and emit websocket status including error and manual_disconnect flag.
Backend — Status & Config
server/services/tuya/lib/tuya.getStatus.js, server/services/tuya/lib/tuya.getConfiguration.js, server/services/tuya/lib/tuya.saveConfiguration.js
New getStatus returns configured/connected/manual_disconnect/error; getConfiguration/save now include appUsername and save resets MANUAL_DISCONNECT.
Backend — Local Ops
server/services/tuya/lib/tuya.localScan.js, server/services/tuya/lib/tuya.localPoll.js, server/services/tuya/lib/tuya.manualDisconnect.js
Added UDP localScan (ports 6666/6667/7000), TCP localPoll with protocol retries/timeouts/socket error handling, discovered-device update helpers, and manualDisconnect setter.
Backend — Device Conversion & Discovery
server/services/tuya/lib/device/tuya.convertDevice.js, server/services/tuya/lib/tuya.loadDeviceDetails.js, server/services/tuya/lib/tuya.loadDevices.js, server/services/tuya/lib/tuya.discoverDevices.js
convertDevice adds params and local/cloud fields; loadDeviceDetails fetches specs+details in parallel; loadDevices uses page_no/page_size + PUBLIC_VERSION_1_0; discovery merges existing local params.
Backend — Device Writes
server/services/tuya/lib/tuya.setValue.js
Attempts local writes via TuyAPI when device.params provide local config (ip/localKey/protocolVersion/localOverride) with DPS mapping; falls back to cloud on failure.
Backend — Utilities & Constants
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/utils/constants.js
Added DEVICE_PARAM_NAME, GLADYS_VARIABLES.APP_USERNAME and MANUAL_DISCONNECT, PUBLIC_VERSION_1_0, device param helpers (upsert/apply/update), normalizeBoolean, and WEBSOCKET_MESSAGE_TYPES.TUYA.ERROR.
Dependencies
server/services/tuya/package.json
Added dependency tuyapi for local Tuya interactions.
Tests
server/test/services/tuya/lib/**, server/test/services/tuya/lib/controllers/tuya.controller.test.js
Extensive tests added/updated for localScan/localPoll, getStatus, manualDisconnect, convertDevice, connect/disconnect/init flows, loadDevices/loadDeviceDetails, saveConfiguration, setValue local path, device param utils, and controller endpoints; uses proxyquire/sinon stubs.

Sequence Diagram(s)

sequenceDiagram
    participant User as User/UI
    participant Frontend as Frontend
    participant Backend as Backend
    participant LocalDevice as Local Tuya Device
    participant TuyaCloud as Tuya Cloud

    User->>Frontend: Trigger "Local Scan"
    Frontend->>Backend: POST /api/v1/service/tuya/local-scan
    Backend->>LocalDevice: Bind UDP ports (6666/6667/7000) and listen
    LocalDevice-->>Backend: UDP discovery packets
    Backend->>Backend: Parse packets, aggregate local devices & portErrors
    Backend-->>Frontend: Return local_devices + portErrors
    Frontend->>User: Display local discovery results

    User->>Frontend: Click "Poll Local" on device
    Frontend->>Backend: POST /api/v1/service/tuya/local-poll (id, ip, key, proto)
    Backend->>LocalDevice: TCP connect (try 3.5 → 3.4 → 3.3 → 3.1)
    LocalDevice-->>Backend: DPS response
    Backend->>Backend: Update discovered device params (ip, protocol, local_key)
    Backend-->>Frontend: Return poll result (dps or error)
    Frontend->>User: Show poll success/error
Loading
sequenceDiagram
    participant SetupUI as Setup UI
    participant API as Backend API
    participant TuyaService as Tuya Service
    participant Gladys as Gladys Variables
    participant WebSocket as WebSocket

    SetupUI->>API: GET configuration (endpoint, accessKey, secretKey, appUsername)
    API->>Gladys: gladys.variable.getValue(...) xN
    Gladys-->>API: config values
    API-->>SetupUI: configuration loaded

    SetupUI->>API: POST /api/v1/service/tuya/status
    API->>TuyaService: tuyaManager.getStatus()
    TuyaService->>Gladys: read MANUAL_DISCONNECT & credentials
    Gladys-->>TuyaService: values
    TuyaService-->>API: { status, connected, configured, error, manual_disconnect }
    API-->>SetupUI: status result
    WebSocket-->>SetupUI: TUYA.STATUS events update UI in real-time
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Suggested reviewers

  • Pierre-Gilles

Poem

🐇 I hopped through ports and wiggled my nose,
found little packets where the Tuya wind blows.
I polled and I patched, masked secrets with care,
merged cloud and LAN in a cozy little lair.
— Hop, a rabbit's fix for devices everywhere.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Tuya - local baseline - PR1' is vague and generic; it lacks specificity about the actual changes (local mode support, device discovery, polling) and uses non-descriptive terms. Replace with a more descriptive title such as 'feat(tuya): Add local device mode support and UDP discovery' or 'Tuya: Implement local polling and UDP device discovery' to clearly convey the primary changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

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: 10

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx (1)

144-179: ⚠️ Potential issue | 🟡 Minor

Compute “configured” using trimmed credentials.

configured is derived from raw state, but you .trim() before saving. Whitespace-only values will mark the UI as configured and attempt a start, while saving empty strings. Trim once and reuse the trimmed values for both saving and config checks.

🐛 Suggested fix
-      await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_ACCESS_KEY', {
-        value: this.state.tuyaAccessKey.trim()
-      });
+      const tuyaEndpoint = this.state.tuyaEndpoint.trim();
+      const tuyaAccessKey = this.state.tuyaAccessKey.trim();
+      const tuyaSecretKey = this.state.tuyaSecretKey.trim();
+      const tuyaAppAccountId = this.state.tuyaAppAccountId.trim();
+      const tuyaAppUsername = this.state.tuyaAppUsername.trim();
+
+      await this.props.httpClient.post('/api/v1/service/tuya/variable/TUYA_ACCESS_KEY', {
+        value: tuyaAccessKey
+      });
...
-      const configured = !!(this.state.tuyaEndpoint && this.state.tuyaAccessKey && this.state.tuyaSecretKey);
+      const configured = !!(tuyaEndpoint && tuyaAccessKey && tuyaSecretKey);
🤖 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
144 - 179, The configured check uses raw state while you trim values before
saving, so create trimmed local variables (e.g., const tuyaAccessKey =
this.state.tuyaAccessKey.trim(), const tuyaSecretKey =
this.state.tuyaSecretKey.trim(), and trim tuyaAppAccountId and tuyaAppUsername
similarly), use those trimmed variables in the httpClient.post calls (instead of
this.state.*.trim() inline) and compute configured with the trimmed credentials
(e.g., const configured = !!(this.state.tuyaEndpoint && tuyaAccessKey &&
tuyaSecretKey)); update any subsequent logic that relies on the values to use
the trimmed variables.
🧹 Nitpick comments (7)
front/src/config/i18n/en.json (3)

1121-1121: Consider clarifying scan progress message for both cloud and local scans.

The message "Scan in progress... retrieving cloud devices." only mentions cloud devices, but based on the PR objectives, the scan functionality now includes both cloud and local device discovery. Consider updating to something like "Scan in progress..." or "Scan in progress... retrieving devices." to be accurate for both scan types.

Suggested fix
-        "scanInProgress": "Scan in progress... retrieving cloud devices.",
+        "scanInProgress": "Scan in progress...",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@front/src/config/i18n/en.json` at line 1121, The "scanInProgress" i18n
message currently reads "Scan in progress... retrieving cloud devices." but the
scan now covers both cloud and local devices; update the translation for the
"scanInProgress" key to a neutral phrase such as "Scan in progress..." or "Scan
in progress... retrieving devices." so it accurately reflects both cloud and
local scans—locate the "scanInProgress" entry in en.json and replace the string
accordingly.

1166-1168: Consider consolidating duplicate success messages.

Lines 1166-1168 contain very similar messages:

  • connected: "Connected to the Tuya cloud account with success !"
  • connectedAfterSave: "Connected to the Tuya cloud account with success !"
  • connectedStatus: "Connected to the Tuya cloud account."

The first two are identical. If they serve different UI purposes, consider differentiating them (e.g., one could include additional context). If not, consider using a single key to avoid maintenance overhead and ensure consistency.

🤖 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 1166 - 1168, The three i18n keys
connected, connectedAfterSave, and connectedStatus are duplicated/overlapping;
either consolidate them into a single canonical key (e.g., connected) and update
usages to reference it, or differentiate their copy text to reflect distinct UI
contexts (e.g., keep connected for generic success, change connectedAfterSave to
"Saved and connected to the Tuya cloud account." and keep connectedStatus as the
short status "Connected to the Tuya cloud account."). Update all references in
the codebase to use the chosen key/text and remove any redundant key to avoid
maintenance drift.

1166-1167: Minor: Remove space before exclamation marks.

The messages have a space before the exclamation mark ("with success !"), which is a French typographic convention. In English, there should be no space before the exclamation mark.

Suggested fix
         "connected": "Connected to the Tuya cloud account with success !",
-        "connectedAfterSave": "Connected to the Tuya cloud account with success !",
+        "connected": "Connected to the Tuya cloud account with success!",
+        "connectedAfterSave": "Connected to the Tuya cloud account with success!",
🤖 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 1166 - 1167, Update the two i18n
entries "connected" and "connectedAfterSave" to remove the extra space before
the exclamation mark (change "with success !" to "with success!") so they follow
English punctuation rules; edit the values for keys connected and
connectedAfterSave in the en.json resource accordingly (no other logic changes).
server/services/tuya/lib/tuya.localPoll.js (1)

26-31: Duplicate error listeners may cause confusion.

Line 31 registers a persistent onError listener that stores lastError and logs, while line 69 registers a one-time errorListener to reject the promise. Both fire on the same error event. The persistent listener on line 31 is never removed (only the one-time listener is removed in cleanup on line 46).

While this doesn't cause a functional bug, consider removing the persistent listener in the final cleanup or consolidating the error handling.

Proposed fix: Remove persistent listener in final cleanup
   } catch (e) {
     if (lastError && (!e || e.message !== lastError.message)) {
       logger.info(`[Tuya][localPoll] last socket error for device=${deviceId}: ${lastError.message}`);
     }
     logger.warn(`[Tuya][localPoll] failed for device=${deviceId}`, e);
     try {
+      tuyaLocal.removeListener('error', onError);
       await tuyaLocal.disconnect();
     } catch (err) {
       // ignore
     }
     throw e;
   }

Also add removal after successful completion around line 100:

     logger.debug(`[Tuya][localPoll] device=${deviceId} dps=${JSON.stringify(data)}`);
+    tuyaLocal.removeListener('error', onError);
     return data;

Also applies to: 65-70

🤖 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 26 - 31, The
persistent error listener on tuyaLocal (onError which assigns lastError and
logs) is registered but not removed, causing duplicate handlers alongside the
one-time errorListener; fix by removing the persistent listener in the same
cleanup path that removes errorListener and other handlers (use tuyaLocal.off/
removeListener for onError), or alternatively consolidate error handling by
using only onError and removing the one-time errorListener registration (ensure
you remove onError on both success and failure). Ensure you reference and remove
the exact handler function name onError wherever you currently remove
errorListener so no duplicate listeners remain.
server/test/services/tuya/lib/tuya.localPoll.test.js (1)

128-159: Ensure fake timers are restored even on unexpected failures.

If an assertion fails unexpectedly before reaching clock.restore(), the fake timers will leak into subsequent tests. Using try/finally ensures cleanup regardless of how the test exits.

Proposed fix using try/finally
   it('should timeout', async () => {
     const clock = sinon.useFakeTimers();
-    const connect = sinon.stub().resolves();
-    const get = sinon.stub().returns(new Promise(() => {}));
-    const disconnect = sinon.stub().resolves();
-    function TuyAPIStub() {
-      this.connect = connect;
-      this.get = get;
-      this.disconnect = disconnect;
-    }
-    const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
-      tuyapi: TuyAPIStub,
-    });
-    const promise = localPoll({
-      deviceId: 'device',
-      ip: '1.1.1.1',
-      localKey: 'key',
-      protocolVersion: '3.3',
-      timeoutMs: 1000,
-    });
-    await clock.tickAsync(1100);
     try {
-      await promise;
-    } catch (e) {
-      expect(e).to.be.instanceOf(BadParameters);
-      expect(e.message).to.equal('Local poll timeout');
-      clock.restore();
-      return;
+      const connect = sinon.stub().resolves();
+      const get = sinon.stub().returns(new Promise(() => {}));
+      const disconnect = sinon.stub().resolves();
+      function TuyAPIStub() {
+        this.connect = connect;
+        this.get = get;
+        this.disconnect = disconnect;
+      }
+      const { localPoll } = proxyquire('../../../../services/tuya/lib/tuya.localPoll', {
+        tuyapi: TuyAPIStub,
+      });
+      const promise = localPoll({
+        deviceId: 'device',
+        ip: '1.1.1.1',
+        localKey: 'key',
+        protocolVersion: '3.3',
+        timeoutMs: 1000,
+      });
+      await clock.tickAsync(1100);
+      try {
+        await promise;
+      } catch (e) {
+        expect(e).to.be.instanceOf(BadParameters);
+        expect(e.message).to.equal('Local poll timeout');
+        return;
+      }
+      throw new Error('Expected error');
+    } finally {
+      clock.restore();
     }
-    clock.restore();
-    throw new Error('Expected error');
   });
🤖 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 128 - 159,
The test 'should timeout' currently calls clock.restore() only on the success
path and may leak fake timers if an assertion throws; wrap the promise awaiting
and assertions in a try/catch/finally so clock.restore() is always called: after
creating the promise and calling clock.tickAsync(1100) run the await promise
inside a try where the success path throws 'Expected error', handle the expected
BadParameters assertions in catch, and always call clock.restore() in the
finally block; reference the test name 'should timeout', the Sinon fake timer
variable 'clock', and the function under test 'localPoll' to locate the change.
server/services/tuya/api/tuya.controller.js (1)

60-98: Consider extracting body parameters before calling localPoll.

Line 61 calls tuyaManager.localPoll(req.body) before extracting { deviceId, ip, protocolVersion, localKey } on line 62. While this works, extracting parameters first would make the code flow clearer and allow for potential validation before the network call.

Minor reorder suggestion
   async function localPoll(req, res) {
+    const { deviceId, ip, protocolVersion, localKey } = req.body || {};
     const result = await tuyaManager.localPoll(req.body);
-    const { deviceId, ip, protocolVersion, localKey } = req.body || {};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/services/tuya/api/tuya.controller.js` around lines 60 - 98, Extract
req.body parameters (deviceId, ip, protocolVersion, localKey) before calling
tuyaManager.localPoll so you can validate or mutate them prior to the network
call; move the destructuring currently on line 62 up above the call to
tuyaManager.localPoll(req.body) in the localPoll function, perform any
lightweight validation/normalization on deviceId/ip/protocolVersion/localKey,
then call tuyaManager.localPoll with the (possibly validated) body and continue
the existing logic that updates tuyaManager.discoveredDevices and uses
upsertParam.
server/services/tuya/index.js (1)

48-53: Consider guarding interval setup against init failures.

The reconnect interval is always started regardless of whether init() succeeds. If initialization fails permanently (e.g., invalid credentials), the interval will keep attempting reconnection every 30 minutes. While tryReconnect handles errors gracefully, this may generate unnecessary log noise and CPU cycles.

Consider only starting the interval when tuyaHandler.status indicates a recoverable state, or add a failure counter to back off after repeated failures.

🤖 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 48 - 53, The reconnect interval
is being started unconditionally; change the logic so reconnectInterval is only
created when the handler is in a recoverable state (e.g., check
tuyaHandler.status against your STATUS enum and only set reconnectInterval when
status is STATUS.CONNECTED, STATUS.DISCONNECTED, or STATUS.CONNECTING but NOT
STATUS.FAILED), or alternatively initialize a failure counter/backoff inside
tryReconnect and only start the interval after a successful init() or the first
recoverable failure; update the block around tuyaHandler.status,
reconnectInterval, tryReconnect and RECONNECT_INTERVAL_MS to perform this guard
so permanent init failures don’t spawn endless intervals.
🤖 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 1219-1220: The "productKeyLabel" value is still English; update
the translation for the key "productKeyLabel" to German (e.g., change "Product
Key" to "Produktschlüssel") so it matches the localized "productIdLabel" and the
rest of the Tuya section.

In `@front/src/config/i18n/fr.json`:
- Around line 1349-1350: Translate the remaining English label "productKeyLabel"
to French and correct the pluralization in the related "notConfigured" message:
replace the value of productKeyLabel (currently "Product Key") with an
appropriate French phrase (e.g., "Clé produit") and update the "notConfigured"
string so it uses the plural form of "informations" or rephrase to a
grammatically correct French sentence (e.g., "Aucune information configurée" or
"Informations non configurées") where the key name is "notConfigured" (also
ensure identical fixes are applied at the other occurrence around line 1400).

In `@front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx`:
- Around line 111-170: The pollLocal flow sets device from response.device
earlier but later reconstructs device using this.state.device and old params,
which can drop fields from the poll response; update the final setState to base
the new device on the most recent updatedDevice (if present) instead of
this.state.device and merge/replace its params with newParams (using symbols:
pollLocal, response.device/updatedDevice, this.state.device, params/newParams,
usedProtocol) so the polled payload fields are preserved while only updating
PROTOCOL_VERSION.
- Around line 181-198: In updateIpAddress, the CLOUD_IP param lookup currently
falls back to undefined so overrideValue can be wrong when the backend omitted
the param; change the cloudIp assignment to use the device's cloud_ip fallback
(e.g., set cloudIp = cloudIpParam ? cloudIpParam.value :
this.state.device.cloud_ip) so LOCAL_OVERRIDE logic (and params updates for
'LOCAL_OVERRIDE' and 'IP_ADDRESS') correctly reflects differences between the
entered IP and the device.cloud_ip.

In `@server/services/tuya/lib/device/tuya.convertDevice.js`:
- Around line 57-60: The current logger.debug(JSON.stringify(tuyaDevice)) call
in tuya.convertDevice.js exposes sensitive fields (e.g., local_key, token, ip,
gwId); replace it with a redacted summary by building an object from tuyaDevice
that only includes safe identifiers (e.g., id, name, productName/model,
category, online status) and omits or masks sensitive keys (specifically
remove/mask local_key, token, ip, gwId, and any credential fields) before
logging; update the logging around the tuyaDevice variable and the nearby
logger.debug that references name/productName to use this redactedSummary so
only non-sensitive info is written to logs.

In `@server/services/tuya/lib/tuya.loadDeviceDetails.js`:
- Around line 15-31: The debug logs in tuya.loadDeviceDetails.js currently dump
raw device payloads (variables specifications and details) which may contain
secrets like local_key and IPs; update the logging in the loadDeviceDetails flow
to stop printing full JSON — either remove these logger.debug calls or replace
them with a redacted summary that omits sensitive fields (e.g., local_key,
token, ip, gwId) and only logs non-sensitive identifiers (deviceId, model,
productName) or a boolean flag indicating presence of those fields; locate the
two logger.debug lines referring to specifications and details and change them
to redact or remove raw payload output.

In `@server/services/tuya/lib/tuya.localScan.js`:
- Around line 70-83: The payload sometimes omits payload.ip so local discovery
should fall back to the sender address; update the localScan handling that sets
devices[deviceId] so the ip stored is payload.ip when present, otherwise use
rinfo.address (or the existing remote variable) as a fallback. Locate the block
that computes deviceId and assigns devices[deviceId] and change the ip
assignment to use the fallback (keeping version and productKey unchanged) so
missing payload.ip no longer results in an empty IP for the device.
- Around line 51-59: The info-level logging in tuya.localScan.js currently
prints raw packet hex/ascii and the full parsed object (logger.info +
formatHex/formatAscii + JSON.stringify(parsed)), which can expose device data;
change those logger.info calls to logger.debug and remove or redact raw payloads
and full parsed output—log only a minimal summary (e.g., remote/source and
byteLen and a redacted marker or select safe fields) and avoid printing
parsed[0].payload or full JSON; update the log lines around parser.parse,
formatHex, formatAscii, and the parsed payload assignment to use debug/redaction
instead of info/full dumps.

In `@server/services/tuya/lib/tuya.setValue.js`:
- Around line 73-88: The local Tuya path may connect but skip disconnect if
tuyaLocal.set throws, leaking sockets; modify the block that creates TuyAPI and
calls tuyaLocal.connect()/set()/disconnect() (references: hasLocalConfig,
localDps, new TuyAPI, tuyaLocal.connect, tuyaLocal.set, tuyaLocal.disconnect) to
ensure disconnect is always invoked by using a try/finally (or ensuring
disconnect in the catch) so that tuyaLocal.disconnect() runs whether set()
succeeds or throws, and keep the existing logger.warn fallback to cloud on
error.

In `@server/test/services/tuya/lib/controllers/tuya.controller.test.js`:
- Around line 135-149: The test reassigns tuyaManager.localScan directly which
pollutes global state across tests; instead, change the test to stub/spy and
restore the original between tests by using sinon.stub/replace (or saving the
original function and restoring it in beforeEach/afterEach) for
tuyaManager.localScan so the fake does not persist—update the test that calls
controller['post /api/v1/service/tuya/local-scan'].controller to use a
sinon.stub/resolves for tuyaManager.localScan and ensure the original is
restored in the test harness (beforeEach/afterEach) so subsequent tests see the
real implementation and tuyaManager.discoveredDevices remains isolated.

---

Outside diff comments:
In `@front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx`:
- Around line 144-179: The configured check uses raw state while you trim values
before saving, so create trimmed local variables (e.g., const tuyaAccessKey =
this.state.tuyaAccessKey.trim(), const tuyaSecretKey =
this.state.tuyaSecretKey.trim(), and trim tuyaAppAccountId and tuyaAppUsername
similarly), use those trimmed variables in the httpClient.post calls (instead of
this.state.*.trim() inline) and compute configured with the trimmed credentials
(e.g., const configured = !!(this.state.tuyaEndpoint && tuyaAccessKey &&
tuyaSecretKey)); update any subsequent logic that relies on the values to use
the trimmed variables.

---

Nitpick comments:
In `@front/src/config/i18n/en.json`:
- Line 1121: The "scanInProgress" i18n message currently reads "Scan in
progress... retrieving cloud devices." but the scan now covers both cloud and
local devices; update the translation for the "scanInProgress" key to a neutral
phrase such as "Scan in progress..." or "Scan in progress... retrieving
devices." so it accurately reflects both cloud and local scans—locate the
"scanInProgress" entry in en.json and replace the string accordingly.
- Around line 1166-1168: The three i18n keys connected, connectedAfterSave, and
connectedStatus are duplicated/overlapping; either consolidate them into a
single canonical key (e.g., connected) and update usages to reference it, or
differentiate their copy text to reflect distinct UI contexts (e.g., keep
connected for generic success, change connectedAfterSave to "Saved and connected
to the Tuya cloud account." and keep connectedStatus as the short status
"Connected to the Tuya cloud account."). Update all references in the codebase
to use the chosen key/text and remove any redundant key to avoid maintenance
drift.
- Around line 1166-1167: Update the two i18n entries "connected" and
"connectedAfterSave" to remove the extra space before the exclamation mark
(change "with success !" to "with success!") so they follow English punctuation
rules; edit the values for keys connected and connectedAfterSave in the en.json
resource accordingly (no other logic changes).

In `@server/services/tuya/api/tuya.controller.js`:
- Around line 60-98: Extract req.body parameters (deviceId, ip, protocolVersion,
localKey) before calling tuyaManager.localPoll so you can validate or mutate
them prior to the network call; move the destructuring currently on line 62 up
above the call to tuyaManager.localPoll(req.body) in the localPoll function,
perform any lightweight validation/normalization on
deviceId/ip/protocolVersion/localKey, then call tuyaManager.localPoll with the
(possibly validated) body and continue the existing logic that updates
tuyaManager.discoveredDevices and uses upsertParam.

In `@server/services/tuya/index.js`:
- Around line 48-53: The reconnect interval is being started unconditionally;
change the logic so reconnectInterval is only created when the handler is in a
recoverable state (e.g., check tuyaHandler.status against your STATUS enum and
only set reconnectInterval when status is STATUS.CONNECTED, STATUS.DISCONNECTED,
or STATUS.CONNECTING but NOT STATUS.FAILED), or alternatively initialize a
failure counter/backoff inside tryReconnect and only start the interval after a
successful init() or the first recoverable failure; update the block around
tuyaHandler.status, reconnectInterval, tryReconnect and RECONNECT_INTERVAL_MS to
perform this guard so permanent init failures don’t spawn endless intervals.

In `@server/services/tuya/lib/tuya.localPoll.js`:
- Around line 26-31: The persistent error listener on tuyaLocal (onError which
assigns lastError and logs) is registered but not removed, causing duplicate
handlers alongside the one-time errorListener; fix by removing the persistent
listener in the same cleanup path that removes errorListener and other handlers
(use tuyaLocal.off/ removeListener for onError), or alternatively consolidate
error handling by using only onError and removing the one-time errorListener
registration (ensure you remove onError on both success and failure). Ensure you
reference and remove the exact handler function name onError wherever you
currently remove errorListener so no duplicate listeners remain.

In `@server/test/services/tuya/lib/tuya.localPoll.test.js`:
- Around line 128-159: The test 'should timeout' currently calls clock.restore()
only on the success path and may leak fake timers if an assertion throws; wrap
the promise awaiting and assertions in a try/catch/finally so clock.restore() is
always called: after creating the promise and calling clock.tickAsync(1100) run
the await promise inside a try where the success path throws 'Expected error',
handle the expected BadParameters assertions in catch, and always call
clock.restore() in the finally block; reference the test name 'should timeout',
the Sinon fake timer variable 'clock', and the function under test 'localPoll'
to locate the change.

@Terdious Terdious removed the request for review from Pierre-Gilles February 20, 2026 21:05
@Terdious
Copy link
Copy Markdown
Contributor Author

Terdious commented Mar 13, 2026

@coderabbitai full review

Please review the full PR again, but keep feedback limited to the actual scope of this PR and the files impacted by it. Focus on concrete regressions, inconsistencies with existing Gladys patterns, and real risks for current behavior. Avoid speculative improvements, overengineering, or suggestions unrelated to the current implementation goals.

Please review this PR with these priorities:

Compare the code style and patterns to the existing Gladys codebase and avoid suggestions that diverge from current project habits.
Focus on consistency with the current repository conventions rather than proposing new architectural patterns.
Avoid overengineering and speculative hardening for hypothetical future use cases.
Prioritize concrete risks: regressions, broken existing behavior, inconsistent tests, edge cases already relevant to the current implementation, and mismatch with surrounding code.
Be pragmatic: only suggest additional guards or abstractions if they are clearly needed by the current use case.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 13, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

I'll gather the actual code to conduct a thorough, focused review.


🏁 Script executed:

#!/bin/bash
# Read key server files
echo "=== tuya.localPoll.js ==="
cat server/services/tuya/lib/tuya.localPoll.js

echo ""
echo "=== tuya.localScan.js ==="
cat server/services/tuya/lib/tuya.localScan.js

echo ""
echo "=== tuya.setValue.js ==="
cat server/services/tuya/lib/tuya.setValue.js

echo ""
echo "=== tuya.connect.js ==="
cat server/services/tuya/lib/tuya.connect.js

echo ""
echo "=== tuya.disconnect.js ==="
cat server/services/tuya/lib/tuya.disconnect.js

echo ""
echo "=== server/services/tuya/index.js ==="
cat server/services/tuya/index.js

echo ""
echo "=== tuya.controller.js ==="
cat server/services/tuya/api/tuya.controller.js

Length of output: 29598


🏁 Script executed:

#!/bin/bash
echo "=== tuya.getStatus.js ==="
cat server/services/tuya/lib/tuya.getStatus.js

echo ""
echo "=== tuya.init.js ==="
cat server/services/tuya/lib/tuya.init.js

echo ""
echo "=== tuya.discoverDevices.js ==="
cat server/services/tuya/lib/tuya.discoverDevices.js

echo ""
echo "=== tuya.loadDeviceDetails.js ==="
cat server/services/tuya/lib/tuya.loadDeviceDetails.js

echo ""
echo "=== tuya.loadDevices.js ==="
cat server/services/tuya/lib/tuya.loadDevices.js

echo ""
echo "=== tuya.deviceParams.js ==="
cat server/services/tuya/lib/utils/tuya.deviceParams.js

echo ""
echo "=== tuya.constants.js ==="
cat server/services/tuya/lib/utils/tuya.constants.js

echo ""
echo "=== tuya.normalize.js ==="
cat server/services/tuya/lib/utils/tuya.normalize.js

Length of output: 17052


🏁 Script executed:

#!/bin/bash
echo "=== DiscoverTab.jsx ==="
cat front/src/routes/integration/all/tuya/discover-page/DiscoverTab.jsx

echo ""
echo "=== SetupTab.jsx (first 200 lines) ==="
head -200 front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx

echo ""
echo "=== SetupTab.jsx (rest) ==="
tail -n +201 front/src/routes/integration/all/tuya/setup-page/SetupTab.jsx

echo ""
echo "=== TuyaDeviceBox.jsx (first 200 lines) ==="
head -200 front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx

Length of output: 35849


🏁 Script executed:

#!/bin/bash
echo "=== TuyaDeviceBox.jsx (rest) ==="
tail -n +201 front/src/routes/integration/all/tuya/TuyaDeviceBox.jsx

echo ""
echo "=== i18n protocol35Unsupported and limitationsTitle in all locales ==="
rg -n "protocol35Unsupported\|limitationsTitle\|limitationsProtocol35" front/src/config/i18n/

echo ""
echo "=== tuya.manualDisconnect.js ==="
cat server/services/tuya/lib/tuya.manualDisconnect.js

echo ""
echo "=== tuya.saveConfiguration.js ==="
cat server/services/tuya/lib/tuya.saveConfiguration.js

echo ""
echo "=== tuya.getConfiguration.js ==="
cat server/services/tuya/lib/tuya.getConfiguration.js

echo ""
echo "=== server/services/tuya/lib/index.js ==="
cat server/services/tuya/lib/index.js

echo ""
echo "=== device/tuya.convertDevice.js ==="
cat server/services/tuya/lib/device/tuya.convertDevice.js

Length of output: 29111


🏁 Script executed:

#!/bin/bash
echo "=== Check specific i18n keys in EN locale ==="
rg -n "protocol35Unsupported\|protocol35Option\|localModeLimitInfo\|limitationsTitle\|limitationsProtocol35\|localScanAuto\|scanCloud\|udpScanPortInUse" front/src/config/i18n/en.json

echo ""
echo "=== Check specific i18n keys in FR locale ==="
rg -n "protocol35Unsupported\|protocol35Option\|localModeLimitInfo\|limitationsTitle\|limitationsProtocol35\|localScanAuto\|scanCloud\|udpScanPortInUse" front/src/config/i18n/fr.json

echo ""
echo "=== Check specific i18n keys in DE locale ==="
rg -n "protocol35Unsupported\|protocol35Option\|localModeLimitInfo\|limitationsTitle\|limitationsProtocol35\|localScanAuto\|scanCloud\|udpScanPortInUse" front/src/config/i18n/de.json

echo ""
echo "=== Check all integration.tuya.device keys in EN ==="
rg -n '"integration.tuya.device\.' front/src/config/i18n/en.json || grep -n '"device"' front/src/config/i18n/en.json | head -5

echo ""
echo "=== Check tuya section structure in EN (near 'device') ==="
python3 -c "
import json
with open('front/src/config/i18n/en.json') as f:
    data = json.load(f)
tuya = data.get('integration', {}).get('tuya', {})
device = tuya.get('device', {})
print('tuya.device keys:', list(device.keys()))
print()
discover = tuya.get('discover', {})
print('tuya.discover keys:', list(discover.keys()))
print()
setup = tuya.get('setup', {})
print('tuya.setup keys:', list(setup.keys()))
print()
print('Top-level tuya keys:', list(tuya.keys()))
"

echo ""
echo "=== Check LAST_CONNECTED_CONFIG_HASH usage in connect and init ==="
rg -n "LAST_CONNECTED_CONFIG_HASH\|buildConfigHash" server/services/tuya/

echo ""
echo "=== Check tuya.config.js exists ==="
fd "tuya.config" server/services/tuya/

echo ""
echo "=== Check if saveConfiguration is called anywhere in frontend or tests ==="
rg -n "saveConfiguration" server/services/tuya/ front/src/routes/integration/all/tuya/

echo ""
echo "=== Check autoReconnectAllowed usage ==="
rg -n "autoReconnectAllowed" server/services/tuya/

Length of output: 3675


@Terdious — full review complete. Here are concrete findings scoped to the current implementation goals, grouped by impact.


🔴 Concrete Bugs / Regressions

1. protocol35Unsupported rendered for a wrong condition (TuyaDeviceBox.jsx)

In the render, the protocol35Unsupported message is displayed whenever no protocol is selected at all — not specifically when 3.5 is chosen:

{!showCloudIp && !protocolVersion && (
  <div class="text-danger mt-2">
    <Text id="integration.tuya.device.protocol35Unsupported" />
  </div>
)}

If the i18n text says something like "Protocol 3.5 is not supported locally", it will be shown incorrectly for users who simply haven't selected any version yet. The condition and the message are mismatched. It should either use a different key (e.g. protocolVersionRequired) or the condition should be narrowed to protocolVersion === '3.5'.


2. Double disconnect in localPoll error path (tuya.localPoll.js)

In the outer catch block of localPoll, tuyaLocal.disconnect() is called explicitly:

// outer catch
try {
  tuyaLocal.removeListener('error', onError);
  await tuyaLocal.disconnect();  // ← second disconnect
} catch (err) { /* ignore */ }

But runGet already calls cleanup() on throw, and cleanup() calls tuyaLocal.disconnect(). On any runGet failure, disconnect is called twice. The second call is silently swallowed, so it doesn't break anything currently — but it adds unnecessary complexity and could trigger unexpected behavior with future TuyAPI versions. The outer catch disconnect should be removed since cleanup() already handles it.


🟡 Real Risks

3. loadDeviceDetails now makes 4 parallel Tuya API calls per device (was 1)

The discovery phase previously fetched 1 endpoint per device (specification v1.2). It now fires 4 in parallel (specification v1.2, device v1.0, shadow properties v2.0, thing model v2.0). For accounts with 50+ devices that means ~200 simultaneous API calls, which can hit the Tuya IoT Core free-tier rate limit and cause many silent partial failures.

Promise.allSettled is used correctly, so individual failures degrade gracefully (consistent with the existing learning). However, it may be worth considering a sequential or throttled approach for the discoverDevices loop if rate limit errors become a user-reported issue in practice.

4. disconnectFromCloud button disabled when user partially edits form (SetupTab.jsx)

disabled={!state.tuyaConfigured || state.tuyaDisconnecting || state.tuyaConnecting}

tuyaConfigured becomes false the moment the user clears any of the four required fields. If a user accidentally edits the Client ID field and then wants to disconnect, the button is grayed out. The disconnect action doesn't need a valid configuration to execute (it's a server-side state clear). Consider removing !state.tuyaConfigured from the disabled condition, or gating only on tuyaDisconnecting || tuyaConnecting.


🟠 Dead Code / Consistency Issues

5. saveConfiguration server handler is now unreachable (tuya.saveConfiguration.js / lib/index.js)

TuyaHandler.prototype.saveConfiguration is registered but no route in tuya.controller.js exposes it, and SetupTab.jsx now writes all variables directly via /api/v1/service/tuya/variable/*. The TUYA_LAST_CONNECTED_CONFIG_HASH reset is also handled directly by the frontend. The handler method is dead code. It won't cause a regression now, but it will confuse future contributors. Recommend either exposing it via a /configuration route (replacing direct variable writes) or removing it.

6. getStatus() includes appAccountId in the configured check, but connect() does not

// tuya.getStatus.js
const configured = Boolean(endpoint && accessKey && secretKey && appAccountId);

// tuya.connect.js
if (!baseUrl || !accessKey || !secretKey) { throw new ServiceNotConfiguredError(...); }

tryReconnect in index.js calls getStatus() to guard reconnects. If appAccountId was somehow cleared after a successful connection, getStatus().configured would be false and auto-reconnect would be skipped — even though connect() itself would accept those credentials and only fail later at validateAppAccount. This is internally consistent (reconnecting without appAccountId would fail anyway), but the divergence between the two "configured" definitions could produce surprising log output. Consider extracting a shared isConfigured({ endpoint, accessKey, secretKey, appAccountId }) helper.


🟢 Missing Test Coverage

7. No test for quick-reconnect service logic (server/services/tuya/index.js)

The scheduleQuickReconnects / clearQuickReconnects / QUICK_RECONNECT_ATTEMPTS path (Lines 68–107) is entirely new and untested. The existing index.test.js only covers start / stop / isUsed. Specifically, the following scenarios have no coverage:

  • Quick reconnect attempts are scheduled when start() finds the handler not connected but autoReconnectAllowed = true.
  • clearQuickReconnects is called in stop() and actually cancels pending timers.
  • tryReconnect returns false (no retry) when autoReconnectAllowed = false.

ℹ️ Minor Notes (no action needed unless you agree)

  • normalizeBoolean is duplicated: tuya.normalize.js (server) also handles 'on' via .trim().toLowerCase(), while the frontend copy in TuyaDeviceBox.jsx handles 'TRUE' but not 'on'. For LOCAL_OVERRIDE param values written and read by the same server code this is fine, but worth keeping in mind if local params are ever exchanged across the boundary.
  • tuya.init.js test for LAST_CONNECTED_CONFIG_HASH: init.js reads this variable to compute hasMatchingConfig, but tuya.init.test.js doesn't stub this call. The behavior falls through to the default undefined === undefined case (which gives hasMatchingConfig = falseautoReconnectAllowed = false). This means init tests always exercise the non-reconnect path. Not a blocking issue but the test coverage of autoReconnectAllowed = true initialization is incomplete.

🧠 Learnings used
Learnt from: Terdious
Repo: GladysAssistant/Gladys PR: 2467
File: server/services/tuya/lib/tuya.loadDeviceDetails.js:34-80
Timestamp: 2026-03-01T08:18:47.685Z
Learning: In server/services/tuya/lib/tuya.loadDeviceDetails.js, the function intentionally returns a device object with whatever partial data is available (specifications, details, properties, thing_model) even when some or all API fetches fail. This degraded-mode return is preferred over throwing errors because Tuya payload availability is often partial, and preserving available data is more useful than failing fast. Discovery should remain resilient.

Learnt from: Terdious
Repo: GladysAssistant/Gladys PR: 2458
File: front/src/config/i18n/fr.json:1351-1356
Timestamp: 2026-02-25T17:03:34.464Z
Learning: In the FR locale (front/src/config/i18n/fr.json), Tuya “local poll” wording standard is “Lecture locale …” across keys:
- integration.tuya.device.localPollButton → "Lecture locale des DP"
- integration.tuya.device.localPollInProgress → "Lecture locale en cours... protocole {{protocol}}."
- integration.tuya.device.localPollHelp → use FR phrasing; currently includes “scan complet (plus long)”
- integration.tuya.device.localPollRequired → "Lancez une lecture locale des DP..."
- integration.tuya.device.localPollSuccess → "Lecture locale OK."
- integration.tuya.device.localPollError → "Échec de la lecture locale :"
Keep this phrasing for future FR translations to avoid mixing EN (“Poll”).

Learnt from: Terdious
Repo: GladysAssistant/Gladys PR: 2438
File: front/src/config/i18n/fr.json:1341-1355
Timestamp: 2026-02-21T16:49:42.954Z
Learning: Tuya integration now supports local protocol 3.5; the translation key integration.tuya.device.protocol35Unsupported was removed across locales to avoid misleading messaging.

Learnt from: Terdious
Repo: GladysAssistant/Gladys PR: 2466
File: front/src/config/i18n/de.json:1224-1228
Timestamp: 2026-02-27T18:47:18.374Z
Learning: In the DE locale (front/src/config/i18n/de.json), Tuya “local poll” wording standard is “Lokale Abfrage …” across keys:
- integration.tuya.device.localPollInProgress → "Lokale Abfrage läuft... Protokoll {{protocol}}."
- integration.tuya.device.localPollSuccess → "Lokale Abfrage OK."
- integration.tuya.device.localPollError → "Lokale Abfrage fehlgeschlagen:"
Prefer this phrasing going forward and avoid mixing EN (“Poll”) in German UI strings.

Learnt from: Terdious
Repo: GladysAssistant/Gladys PR: 2438
File: front/src/config/i18n/fr.json:1341-1355
Timestamp: 2026-02-21T16:49:42.954Z
Learning: The Tuya setup “limitations” block was removed; i18n keys integration.tuya.setup.limitationsTitle and integration.tuya.setup.limitationsProtocol35 no longer exist and should not be referenced in future UI code.

Terdious and others added 3 commits April 4, 2026 11:00
- Rename protocol35Unsupported i18n key to protocolVersionRequired with
  informational message and block 3.5 selection in JS handler
- Remove redundant disconnect() call in localPoll outer catch
- Remove !tuyaConfigured from disconnect button disabled condition
- Expose saveConfiguration via POST /api/v1/service/tuya/configuration
  and simplify frontend to use single endpoint (align with Gladys pattern)
- Add appAccountId to connect() guard for consistency with getStatus()
- Update connect test for new guard behavior

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@Pierre-Gilles Pierre-Gilles left a comment

Choose a reason for hiding this comment

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

Thanks for the PR 🙌 I really think this will help Tuya users discover Gladys!

Quick question: did you build this PR with Claude Code? If so, which model did you use? 🙂

Terdious and others added 4 commits April 5, 2026 19:01
cloudTitle and localTitle i18n keys now contain only plain text.
The <strong> and <br /> markup is handled in SetupTab.jsx.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When Tuya credentials are missing, init() now returns immediately
with NOT_INITIALIZED status instead of attempting a connection.
This avoids unnecessary errors at startup for users who do not
use the Tuya integration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ect.js

Extract tryReconnect, scheduleQuickReconnects and clearQuickReconnects
into a dedicated module with a createReconnectManager() factory.
index.js now follows the standard Gladys service structure.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Terdious
Copy link
Copy Markdown
Contributor Author

Terdious commented Apr 5, 2026

Thanks for the PR 🙌 I really think this will help Tuya users discover Gladys!

Quick question: did you build this PR with Claude Code? If so, which model did you use? 🙂

Thanks! Yes of course, I started the development with Codex GPT-5.2, then continued with GPT-5.3-Max, and finally did a full review pass with Claude Opus 4.6 (Claude Code).

@Terdious Terdious requested a review from Pierre-Gilles April 5, 2026 17:47
- Move reconnect logic into TuyaHandler prototype methods instead of
  factory pattern, following standard Gladys service conventions
- Use ServiceNotConfiguredError in init() when credentials are missing
  instead of silent return, matching existing Gladys patterns
- Normalize localScan parameter to object-only type for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Terdious Terdious requested a review from Pierre-Gilles April 5, 2026 18:32
Terdious and others added 2 commits April 5, 2026 20:39
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Call addSelector() after building device/feature objects, matching the
pattern used by zigbee2mqtt and tasmota. Without this, updating a device
from the discovery page overwrites the slugified selector in DB with the
raw value (e.g. tuya:id:code instead of tuya-id-code), breaking
dashboard widget references.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
if (tuyaHandler.status === STATUS.CONNECTED) {
await tuyaHandler.loadDevices();
}
tuyaHandler.startReconnect();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why isn’t all of this handled in tuyaHandler.init()?

In my opinion, tuyaHandler should behave like a small, self-contained library and be responsible for its own logic.

index.js should remain a minimal bridge between the Gladys core and the integration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The reasoning behind having loadDevices and startReconnect in index.js was separation of concerns: init() handles configuration and connection, while the reconnect mechanism is a layer above that monitors and restarts — a different responsibility. Having it in index.js made the startup orchestration explicitly visible at a glance: init → load devices if connected → start reconnect watcher. We also wanted to avoid startReconnect() being called on every init() invocation if it were ever reused internally (e.g. during reconnect flows).

That said, I understand the convention — the handler should be a self-contained library and index.js a minimal bridge, like netatmo. Moved loadDevices() and startReconnect() into init().

*/
async function stop() {
logger.info('Stopping Tuya service');
tuyaHandler.stopReconnect();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why isn't this done in disconnect?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Same logic here: disconnect() is responsible for cleaning up the connection state (nullify connector, reset status, notify via websocket). stopReconnect() is about stopping the monitoring timers — a separate concern. Keeping them distinct in index.js made the shutdown sequence readable: stop reconnect watcher → then disconnect.

Aligned with the convention — stopReconnect() is now called inside disconnect().

…nect

index.js is now a minimal bridge (like zigbee2mqtt): start() calls
init(), stop() calls disconnect(). All orchestration lives inside the
handler where it belongs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@Terdious Terdious requested a review from Pierre-Gilles April 8, 2026 13:49
Copy link
Copy Markdown
Contributor

@Pierre-Gilles Pierre-Gilles left a comment

Choose a reason for hiding this comment

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

Great PR! 🙌

Just one more tester and we should be good to go. Thanks a lot for the fixes!

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants