homebridge-tuya-plus 3.14.0-dev.33 → 3.14.0-dev.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -131,6 +131,20 @@ devices already paired in HomeKit; a careless change can break them silently.
131
131
  routing.
132
132
  - When a change must alter behavior, make it opt-in.
133
133
 
134
+ ## Debug config block
135
+
136
+ The platform reads an **undocumented** top-level `debug` object from the config
137
+ (see `_debugConfig()` in `index.js`). It holds dev/test-only switches and is
138
+ deliberately **kept out of `config.schema.json`** so it never appears in the
139
+ Homebridge UI. Treat it as the one place such switches belong; add new ones as
140
+ optional, off-by-default flags read through `_debugConfig()` and coerced with
141
+ `coerceBoolean`.
142
+
143
+ - `debug.forceCloudFallback` — pretend LAN discovery fails for every device, so
144
+ the whole platform runs over the Tuya Cloud fallback. Lets the cloud path be
145
+ exercised end-to-end without taking devices off the LAN (needs a configured
146
+ `cloud` block to be useful).
147
+
134
148
  ## Git & PR workflow
135
149
 
136
150
  - Develop on the branch you've been assigned; never push to `main` directly.
package/index.js CHANGED
@@ -79,6 +79,20 @@ let Characteristic,
79
79
  AdaptiveLightingController,
80
80
  UUID;
81
81
 
82
+ // Lenient boolean coercion for platform-level config flags, mirroring
83
+ // BaseAccessory._coerceBoolean so the same true / 'true' / 1 spellings users
84
+ // already use on device options are accepted on top-level options too.
85
+ function coerceBoolean(value, defaultValue) {
86
+ const df = defaultValue || false;
87
+ return typeof value === 'boolean'
88
+ ? value
89
+ : typeof value === 'string'
90
+ ? value.toLowerCase().trim() === 'true'
91
+ : typeof value === 'number'
92
+ ? value !== 0
93
+ : df;
94
+ }
95
+
82
96
  module.exports = function (homebridge) {
83
97
  ({
84
98
  platformAccessory: PlatformAccessory,
@@ -221,6 +235,37 @@ class TuyaLan {
221
235
  return; // cloud-only (or empty) configuration: nothing to discover over the LAN
222
236
  }
223
237
 
238
+ // Debug switch: pretend LAN discovery fails for every device so the whole
239
+ // platform runs over the Tuya Cloud fallback — a way to exercise the cloud
240
+ // path end-to-end without taking devices off the LAN. We simply never start
241
+ // discovery (and never attach a LAN backend), leaving the cloud session each
242
+ // device was already wired with as its only transport. Useless without a
243
+ // configured cloud session — the affected devices then have no transport at
244
+ // all, so say so per device rather than failing silently.
245
+ if (coerceBoolean(this._debugConfig().forceCloudFallback)) {
246
+ this.log.warn(
247
+ 'debug.forceCloudFallback is set: skipping LAN discovery; every device is forced onto the Tuya Cloud fallback.',
248
+ );
249
+ lanDeviceIds.forEach((deviceId) => {
250
+ const tuyaDevice = this.tuyaDevices.get(deviceId);
251
+ if (!tuyaDevice) return;
252
+ if (tuyaDevice.cloud) {
253
+ this.log.info(
254
+ 'Forcing %s (%s) onto the Tuya Cloud fallback (debug.forceCloudFallback).',
255
+ tuyaDevice.context.name,
256
+ deviceId,
257
+ );
258
+ } else {
259
+ this.log.error(
260
+ 'debug.forceCloudFallback is set but %s (%s) has no Tuya Cloud session, so it can\'t be reached.',
261
+ tuyaDevice.context.name,
262
+ deviceId,
263
+ );
264
+ }
265
+ });
266
+ return;
267
+ }
268
+
224
269
  this.log.info('Starting discovery...');
225
270
 
226
271
  TuyaDiscovery.start({ ids: lanDeviceIds, log: this.log }).on(
@@ -279,6 +324,17 @@ class TuyaLan {
279
324
  }, this.config.discoverTimeout ?? DEFAULT_DISCOVER_TIMEOUT);
280
325
  }
281
326
 
327
+ // The undocumented top-level `debug` block holds switches that only matter
328
+ // when developing or testing the plugin (e.g. forcing the Tuya Cloud path).
329
+ // It is intentionally absent from config.schema.json so it never surfaces in
330
+ // the Homebridge UI. Always returns an object so callers can read a flag
331
+ // without first guarding for the block's presence.
332
+ _debugConfig() {
333
+ return this.config.debug && typeof this.config.debug === 'object'
334
+ ? this.config.debug
335
+ : {};
336
+ }
337
+
282
338
  // A device showed up on the LAN that isn't in the user's config. When the
283
339
  // optional cloud session is available we look the device up there first, so
284
340
  // the warning can name it (and hint at its product/category) and the user
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.33",
3
+ "version": "3.14.0-dev.34",
4
4
  "description": "A community-maintained Homebridge plugin for controlling Tuya devices in HomeKit. LAN-first, with an optional Tuya Cloud fallback for any device the LAN can't reach.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -136,6 +136,36 @@ describe('TuyaLan — discovery routing', () => {
136
136
  });
137
137
  });
138
138
 
139
+ describe('TuyaLan — debug.forceCloudFallback', () => {
140
+ test('skips LAN discovery so a keyed device never attaches a LAN backend', () => {
141
+ run({cloud: CLOUD, debug: {forceCloudFallback: true}, devices: [SW()]});
142
+ expect(TuyaDiscovery.start).not.toHaveBeenCalled();
143
+ expect(TuyaDiscovery.on).not.toHaveBeenCalled();
144
+ expect(instanceFor('bf11111111111111').attachLan).not.toHaveBeenCalled();
145
+ });
146
+
147
+ test('the forced device keeps its shared cloud session as its only transport', () => {
148
+ run({cloud: CLOUD, debug: {forceCloudFallback: true}, devices: [SW()]});
149
+ expect(propsFor('bf11111111111111').cloudApi).toBeDefined();
150
+ });
151
+
152
+ test('off (or absent) leaves discovery running as usual', () => {
153
+ run({cloud: CLOUD, debug: {forceCloudFallback: false}, devices: [SW()]});
154
+ expect(TuyaDiscovery.start).toHaveBeenCalledTimes(1);
155
+ });
156
+
157
+ test('lenient boolean spellings are accepted', () => {
158
+ run({cloud: CLOUD, debug: {forceCloudFallback: 'true'}, devices: [SW()]});
159
+ expect(TuyaDiscovery.start).not.toHaveBeenCalled();
160
+ });
161
+
162
+ test('without a cloud session, the unreachable device is reported as an error', () => {
163
+ const platform = run({debug: {forceCloudFallback: true}, devices: [SW()]});
164
+ expect(TuyaDiscovery.start).not.toHaveBeenCalled();
165
+ expect(platform.log.error).toHaveBeenCalled();
166
+ });
167
+ });
168
+
139
169
  describe('TuyaLan — unconfigured device discovery', () => {
140
170
  const UNKNOWN = {id: 'bf99999999999999', ip: '10.0.0.9'};
141
171
  const BARE = 'Discovered a device that has not been configured yet (%s@%s).';