homebridge-tuya-without-developer-account 1.0.2 → 1.0.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.6
4
+
5
+ - Fixed a Homebridge UI issue where clicking **Save Configuration** could leave the custom settings page spinner running indefinitely even when QR authentication data had already been saved.
6
+ - Added timeout handling around the custom UI save flow.
7
+ - Added post-save verification of the plugin config so users receive a clear success or recovery message instead of a permanent spinner.
8
+
9
+
10
+ ## 1.0.5
11
+
12
+ - Added support for DP10 / category `tgq` Tuya dimmer plugs that expose `switch_led` + `bright_value_v2`.
13
+ - Fixed dimmer validation so devices using `bright_value_v2` are exposed as HomeKit Lightbulb accessories with On and Brightness instead of being marked unsupported.
14
+ - Fixed dimmer on/off schema matching so `bright_value_v2` no longer incorrectly searches for `switch_v2` / `switch_led_v2`.
15
+
16
+ ## 1.0.4
17
+
18
+ - Added a Homebridge settings UI helper for air conditioner temperature overrides.
19
+ - Users can now select a detected Tuya device by name instead of manually finding and pasting the device ID.
20
+ - Added a backend UI endpoint that reads the cached Tuya device list from Homebridge `persist/TuyaDeviceList*.json`.
21
+ - AC-looking devices are listed first when metadata suggests they are air conditioners.
22
+ - The UI writes the correct `deviceOverrides[].id` automatically and saves `airConditioner.minTemperature`, `airConditioner.maxTemperature`, and `airConditioner.temperatureStep`.
23
+
24
+ ## 1.0.3
25
+
26
+ - Added user-friendly air conditioner temperature limit overrides under `deviceOverrides[].airConditioner`.
27
+ - Allows per-device HomeKit AC setpoint limits such as 16-31 °C or 17-31 °C.
28
+ - Allows `temperatureStep: 1` to suppress 0.5 °C steps in the Home app.
29
+ - Values are always configured in Celsius; Fahrenheit users see the Home app converted values automatically.
30
+
3
31
  ## 1.0.2
4
32
 
5
33
  - Fixed startup abort when Homebridge UI saves an empty or incomplete `deviceOverrides` row. Invalid override rows without `id` are now skipped with a warning instead of stopping QR cloud startup.
package/README.md CHANGED
@@ -4,6 +4,9 @@
4
4
 
5
5
  # Tuya without developer account for Homebridge
6
6
 
7
+ Current release: **1.0.6**
8
+
9
+
7
10
  A Homebridge platform plugin for Tuya and Smart Life devices that uses **Home Assistant-style Tuya QR Cloud Authentication**.
8
11
 
9
12
  This plugin is designed for users who want to add Tuya / Smart Life devices to HomeKit through Homebridge **without creating a Tuya IoT Developer Platform account** and without entering Tuya cloud project credentials.
@@ -187,8 +190,62 @@ Optional. Use only when a device is discovered with the wrong category or requir
187
190
  }
188
191
  ```
189
192
 
190
- Use `
191
- `global` as the override ID to apply an override globally.
193
+ Use `global` as the override ID to apply an override globally.
194
+
195
+ ### Air conditioner temperature limits
196
+
197
+ Optional. For Wi-Fi AC units, you can limit the Home app setpoint range and step size. Values are always configured in Celsius. If the iPhone/Home app is set to Fahrenheit, HomeKit converts the values automatically.
198
+
199
+ The preferred method is the Homebridge plugin settings UI:
200
+
201
+ 1. Authenticate and let the plugin discover devices at least once.
202
+ 2. Open **Plugins → Tuya without developer account for Homebridge → Settings**.
203
+ 3. In **Air Conditioner Temperature Overrides**, click **Load Detected Devices**.
204
+ 4. Select the AC device by name, for example **Bedroom AC**.
205
+ 5. Enter:
206
+
207
+ ```text
208
+ Min Temperature: 17
209
+ Max Temperature: 31
210
+ Step: 1
211
+ ```
212
+
213
+ 6. Click **Add / Update AC Override**.
214
+ 7. Click **Save Configuration** and restart Homebridge.
215
+
216
+ The UI automatically saves the correct Tuya device ID. Users no longer need to manually find and paste the device ID for this AC override.
217
+
218
+ The saved config looks like this internally:
219
+
220
+ ```json
221
+ {
222
+ "options": {
223
+ "userCode": "YOUR_TUYA_USER_CODE",
224
+ "deviceOverrides": [
225
+ {
226
+ "id": "THE_SELECTED_AC_DEVICE_ID",
227
+ "airConditioner": {
228
+ "minTemperature": 17,
229
+ "maxTemperature": 31,
230
+ "temperatureStep": 1
231
+ }
232
+ }
233
+ ]
234
+ }
235
+ }
236
+ ```
237
+
238
+ For ACs that support 16 °C minimum, set **Min Temperature** to `16`.
239
+
240
+ Fahrenheit display examples:
241
+
242
+ ```text
243
+ 16 °C ≈ 61 °F
244
+ 17 °C ≈ 63 °F
245
+ 31 °C ≈ 88 °F
246
+ ```
247
+
248
+ HomeKit stores temperature characteristic metadata in Celsius. Do not enter Fahrenheit values in the plugin config.
192
249
 
193
250
  ## Troubleshooting
194
251
 
@@ -251,3 +308,8 @@ Version 1.0.1 and later persist refreshed Tuya QR tokens back to the Homebridge
251
308
  ```
252
309
 
253
310
  If this still happens after upgrading, open the plugin settings, clear the saved authentication, generate a new QR code, scan it with the Tuya Smart or Smart Life app, save the configuration, and restart Homebridge. Also confirm the Homebridge host clock is synchronized, because Tuya signed requests depend on the current time.
311
+
312
+ ### DP10 Smart Dimmer Plug / `bright_value_v2` dimmers
313
+
314
+ Version **1.0.5** adds support for DP10-style Tuya dimmer plugs that expose `switch_led` and `bright_value_v2`. These are exposed in HomeKit as Lightbulb accessories with On and Brightness. If the accessory was previously shown as **Not Supported**, remove only that cached accessory in Homebridge UI and restart Homebridge after upgrading.
315
+
@@ -204,3 +204,13 @@ Most category code is pinyin abbreviation of Chinese name.
204
204
 
205
205
 
206
206
  For the undocumented product category, you can try override it to the most similar one. See [ADVANCED_OPTIONS.md](./ADVANCED_OPTIONS.md).
207
+
208
+ ### DP10 / Treatlife Smart Dimmer Plug
209
+
210
+ Supported from **v1.0.5**. These devices normally report category `tgq` and expose:
211
+
212
+ - `switch_led` for On/Off
213
+ - `bright_value_v2` for Brightness
214
+
215
+ They are exposed to HomeKit as a Lightbulb with On and Brightness. After upgrading from an older version where the device showed as unsupported, remove the affected cached accessory from Homebridge UI and restart Homebridge.
216
+
@@ -81,6 +81,35 @@
81
81
  }
82
82
  }
83
83
  }
84
+ },
85
+ "airConditioner": {
86
+ "type": "object",
87
+ "title": "Air Conditioner Temperature Limits",
88
+ "description": "Optional HomeKit temperature range override for Wi-Fi air conditioners. Values are always Celsius; Home app converts to Fahrenheit automatically for users using \u00b0F.",
89
+ "properties": {
90
+ "minTemperature": {
91
+ "type": "number",
92
+ "title": "Minimum temperature (\u00b0C)",
93
+ "description": "Lowest setpoint exposed to HomeKit. Common values are 16 or 17. Leave empty to use Tuya schema.",
94
+ "minimum": 0,
95
+ "maximum": 50
96
+ },
97
+ "maxTemperature": {
98
+ "type": "number",
99
+ "title": "Maximum temperature (\u00b0C)",
100
+ "description": "Highest setpoint exposed to HomeKit. Common value is 31. Leave empty to use Tuya schema.",
101
+ "minimum": 0,
102
+ "maximum": 60
103
+ },
104
+ "temperatureStep": {
105
+ "type": "number",
106
+ "title": "Temperature step (\u00b0C)",
107
+ "description": "Set to 1 to suppress 0.5 \u00b0C steps. Values are Celsius even when Home app displays Fahrenheit.",
108
+ "minimum": 0.1,
109
+ "maximum": 5,
110
+ "default": 1
111
+ }
112
+ }
84
113
  }
85
114
  },
86
115
  "required": [
package/dist/platform.js CHANGED
@@ -95,6 +95,33 @@ class TuyaPlatform {
95
95
  continue;
96
96
  }
97
97
  item.id = id;
98
+ if (item.airConditioner && typeof item.airConditioner === 'object') {
99
+ const normalizedAirConditioner = {};
100
+ const minTemperature = Number(item.airConditioner.minTemperature);
101
+ const maxTemperature = Number(item.airConditioner.maxTemperature);
102
+ const temperatureStep = Number(item.airConditioner.temperatureStep);
103
+ if (Number.isFinite(minTemperature)) {
104
+ normalizedAirConditioner.minTemperature = minTemperature;
105
+ }
106
+ if (Number.isFinite(maxTemperature)) {
107
+ normalizedAirConditioner.maxTemperature = maxTemperature;
108
+ }
109
+ if (Number.isFinite(temperatureStep) && temperatureStep > 0) {
110
+ normalizedAirConditioner.temperatureStep = temperatureStep;
111
+ }
112
+ if (Number.isFinite(normalizedAirConditioner.minTemperature) && Number.isFinite(normalizedAirConditioner.maxTemperature) && normalizedAirConditioner.minTemperature > normalizedAirConditioner.maxTemperature) {
113
+ this.log.warn('[Tuya QR] Air conditioner override for id "%s" has minTemperature greater than maxTemperature. Swapping values.', id);
114
+ const oldMin = normalizedAirConditioner.minTemperature;
115
+ normalizedAirConditioner.minTemperature = normalizedAirConditioner.maxTemperature;
116
+ normalizedAirConditioner.maxTemperature = oldMin;
117
+ }
118
+ if (Object.keys(normalizedAirConditioner).length > 0) {
119
+ item.airConditioner = normalizedAirConditioner;
120
+ } else {
121
+ this.log.warn('[Tuya QR] Ignoring invalid airConditioner override for id "%s" because no numeric temperature values were provided.', id);
122
+ delete item.airConditioner;
123
+ }
124
+ }
98
125
  seenIds.add(id);
99
126
  validOverrides.push(item);
100
127
  }
@@ -221,6 +248,7 @@ class TuyaPlatform {
221
248
  customCategory: deviceConfig?.category,
222
249
  unbridged: deviceConfig?.unbridged ?? false,
223
250
  schemaOverrides: deviceConfig?.schema ? JSON.stringify(deviceConfig.schema) : undefined,
251
+ airConditioner: deviceConfig?.airConditioner ? JSON.stringify(deviceConfig.airConditioner) : undefined,
224
252
  adaptiveLighting: deviceConfig?.adaptiveLighting ?? false,
225
253
  };
226
254
  const { changed: configChanged } = this.configHash.hasConfigChanged(device.id, configToHash);
@@ -234,18 +234,49 @@ class AirConditionerAccessory extends BaseAccessory_1.default {
234
234
  })
235
235
  .setProps({ validValues });
236
236
  }
237
+ getAirConditionerTemperatureProps(schema) {
238
+ const property = schema.property || {};
239
+ const multiple = Math.pow(10, property.scale || 0);
240
+ const props = {
241
+ minValue: Number.isFinite(Number(property.min)) ? Number(property.min) / multiple : 16,
242
+ maxValue: Number.isFinite(Number(property.max)) ? Number(property.max) / multiple : 31,
243
+ minStep: Math.max(0.1, Number.isFinite(Number(property.step)) ? Number(property.step) / multiple : 1),
244
+ };
245
+ const deviceConfig = this.platform.getDeviceConfig?.(this.device);
246
+ const airConditioner = deviceConfig?.airConditioner;
247
+ if (airConditioner && typeof airConditioner === 'object') {
248
+ const minTemperature = Number(airConditioner.minTemperature);
249
+ const maxTemperature = Number(airConditioner.maxTemperature);
250
+ const temperatureStep = Number(airConditioner.temperatureStep);
251
+ if (Number.isFinite(minTemperature)) {
252
+ props.minValue = minTemperature;
253
+ }
254
+ if (Number.isFinite(maxTemperature)) {
255
+ props.maxValue = maxTemperature;
256
+ }
257
+ if (Number.isFinite(temperatureStep) && temperatureStep > 0) {
258
+ props.minStep = Math.max(0.1, temperatureStep);
259
+ }
260
+ if (props.minValue > props.maxValue) {
261
+ this.log.warn('Invalid airConditioner temperature override: minTemperature %s is greater than maxTemperature %s. Swapping values.', props.minValue, props.maxValue);
262
+ const oldMin = props.minValue;
263
+ props.minValue = props.maxValue;
264
+ props.maxValue = oldMin;
265
+ }
266
+ this.log.info('Using air conditioner HomeKit temperature override: min=%s°C, max=%s°C, step=%s°C. Fahrenheit users will see the Home app converted values automatically.', props.minValue, props.maxValue, props.minStep);
267
+ }
268
+ return { props, multiple };
269
+ }
270
+ normalizeTemperatureCommandValue(value, props, multiple) {
271
+ const clamped = (0, util_1.limit)(Number(value), props.minValue, props.maxValue);
272
+ return Math.round(clamped * multiple);
273
+ }
237
274
  configureCoolingThreshouldTemp() {
238
275
  const schema = this.getSchema(...SCHEMA_CODE.TARGET_TEMP);
239
276
  if (!schema) {
240
277
  return;
241
278
  }
242
- const property = schema.property;
243
- const multiple = Math.pow(10, property.scale);
244
- const props = {
245
- minValue: property.min / multiple,
246
- maxValue: property.max / multiple,
247
- minStep: Math.max(0.1, property.step / multiple),
248
- };
279
+ const { props, multiple } = this.getAirConditionerTemperatureProps(schema);
249
280
  this.log.debug('Set props for CoolingThresholdTemperature:', props);
250
281
  this.mainService().getCharacteristic(this.Characteristic.CoolingThresholdTemperature)
251
282
  .onGet(() => {
@@ -264,7 +295,7 @@ class AirConditionerAccessory extends BaseAccessory_1.default {
264
295
  .updateValue(props.minValue);
265
296
  return;
266
297
  }
267
- await this.sendCommands([{ code: schema.code, value: value * multiple }], true);
298
+ await this.sendCommands([{ code: schema.code, value: this.normalizeTemperatureCommandValue(value, props, multiple) }], true);
268
299
  })
269
300
  .setProps(props);
270
301
  }
@@ -273,13 +304,7 @@ class AirConditionerAccessory extends BaseAccessory_1.default {
273
304
  if (!schema) {
274
305
  return;
275
306
  }
276
- const property = schema.property;
277
- const multiple = Math.pow(10, property.scale);
278
- const props = {
279
- minValue: property.min / multiple,
280
- maxValue: property.max / multiple,
281
- minStep: Math.max(0.1, property.step / multiple),
282
- };
307
+ const { props, multiple } = this.getAirConditionerTemperatureProps(schema);
283
308
  this.log.debug('Set props for HeatingThresholdTemperature:', props);
284
309
  this.mainService().getCharacteristic(this.Characteristic.HeatingThresholdTemperature)
285
310
  .onGet(() => {
@@ -298,7 +323,7 @@ class AirConditionerAccessory extends BaseAccessory_1.default {
298
323
  .updateValue(props.maxValue);
299
324
  return;
300
325
  }
301
- await this.sendCommands([{ code: schema.code, value: value * multiple }], true);
326
+ await this.sendCommands([{ code: schema.code, value: this.normalizeTemperatureCommandValue(value, props, multiple) }], true);
302
327
  })
303
328
  .setProps(props);
304
329
  }
@@ -9,7 +9,7 @@ const Name_1 = require("./characteristic/Name");
9
9
  const On_1 = require("./characteristic/On");
10
10
  const SCHEMA_CODE = {
11
11
  ON: ['switch', 'switch_led', 'switch_1', 'switch_led_1'],
12
- BRIGHTNESS: ['bright_value', 'bright_value_1'],
12
+ BRIGHTNESS: ['bright_value', 'bright_value_v2', 'bright_value_1', 'bright_value_2', 'brightness'],
13
13
  };
14
14
  class DimmerAccessory extends BaseAccessory_1.default {
15
15
  requiredSchema() {
@@ -28,10 +28,31 @@ class DimmerAccessory extends BaseAccessory_1.default {
28
28
  const service = this.accessory.getService(_schema.code)
29
29
  || this.accessory.addService(this.Service.Lightbulb, name, _schema.code);
30
30
  (0, Name_1.configureName)(this, service, name);
31
- (0, On_1.configureOn)(this, service, this.getSchema('switch' + suffix, 'switch_led' + suffix));
31
+ const onSchema = this.resolveOnSchemaForBrightness(suffix);
32
+ if (!onSchema) {
33
+ this.log.warn(`No on/off schema found for brightness DP ${_schema.code}. Tried switch/switch_led variants.`);
34
+ }
35
+ (0, On_1.configureOn)(this, service, onSchema);
32
36
  this.configureBrightness(service, suffix);
33
37
  }
34
38
  }
39
+
40
+ resolveOnSchemaForBrightness(suffix) {
41
+ const candidates = [];
42
+ if (suffix) {
43
+ candidates.push('switch' + suffix, 'switch_led' + suffix);
44
+ }
45
+ // bright_value_v2 is a schema generation/version marker, not a separate channel suffix.
46
+ // Devices such as the Treatlife/DP10 Smart dimmer Plug expose:
47
+ // switch_led + bright_value_v2
48
+ // The older logic looked for switch_v2/switch_led_v2 and marked the device unsupported.
49
+ if (suffix === '_v2') {
50
+ candidates.push('switch_led', 'switch');
51
+ }
52
+ candidates.push('switch', 'switch_led', 'switch_1', 'switch_led_1');
53
+ return this.getSchema(...candidates);
54
+ }
55
+
35
56
  configureBrightness(service, suffix) {
36
57
  const schema = this.getSchema('bright_value' + suffix);
37
58
  if (!schema) {
@@ -39,7 +39,8 @@
39
39
  font-size: 0.875rem;
40
40
  opacity: 0.85;
41
41
  }
42
- .tuya-nodev-status {
42
+ .tuya-nodev-status,
43
+ .tuya-nodev-ac-status {
43
44
  margin-top: 12px;
44
45
  }
45
46
  .tuya-nodev-raw {
@@ -49,6 +50,32 @@
49
50
  font-size: 0.8rem;
50
51
  margin-top: 8px;
51
52
  }
53
+ .tuya-nodev-grid {
54
+ display: grid;
55
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
56
+ gap: 12px;
57
+ }
58
+ .tuya-nodev-table-wrap {
59
+ overflow-x: auto;
60
+ margin-top: 12px;
61
+ }
62
+ .tuya-nodev-table {
63
+ width: 100%;
64
+ border-collapse: collapse;
65
+ font-size: 0.9rem;
66
+ }
67
+ .tuya-nodev-table th,
68
+ .tuya-nodev-table td {
69
+ border-bottom: 1px solid rgba(127, 127, 127, 0.2);
70
+ padding: 8px;
71
+ vertical-align: middle;
72
+ }
73
+ .tuya-nodev-table th {
74
+ text-align: left;
75
+ }
76
+ .tuya-nodev-muted-option {
77
+ opacity: 0.75;
78
+ }
52
79
  </style>
53
80
 
54
81
  <div class="tuya-nodev-card">
@@ -87,12 +114,71 @@
87
114
  </div>
88
115
  </div>
89
116
 
117
+ <div class="tuya-nodev-card">
118
+ <div class="tuya-nodev-title">Air Conditioner Temperature Overrides</div>
119
+ <p class="tuya-nodev-small mb-3">
120
+ Select a discovered Tuya AC device and set the Home app temperature range. Values are always Celsius; iOS/Home automatically displays Fahrenheit for users who use °F.
121
+ </p>
122
+
123
+ <div class="form-group">
124
+ <label for="tuyaNodevAcDevice">Select Device</label>
125
+ <select id="tuyaNodevAcDevice" class="form-control">
126
+ <option value="">Load devices first...</option>
127
+ </select>
128
+ <small class="form-text text-muted">AC-looking devices are listed first when the cached Tuya device list contains enough metadata.</small>
129
+ </div>
130
+
131
+ <div class="tuya-nodev-grid">
132
+ <div class="form-group">
133
+ <label for="tuyaNodevAcMin">Min Temperature (°C)</label>
134
+ <input id="tuyaNodevAcMin" class="form-control" type="number" min="0" max="50" step="0.1" value="17">
135
+ </div>
136
+ <div class="form-group">
137
+ <label for="tuyaNodevAcMax">Max Temperature (°C)</label>
138
+ <input id="tuyaNodevAcMax" class="form-control" type="number" min="0" max="60" step="0.1" value="31">
139
+ </div>
140
+ <div class="form-group">
141
+ <label for="tuyaNodevAcStep">Step (°C)</label>
142
+ <input id="tuyaNodevAcStep" class="form-control" type="number" min="0.1" max="5" step="0.1" value="1">
143
+ </div>
144
+ </div>
145
+
146
+ <div class="tuya-nodev-actions">
147
+ <button id="tuyaNodevLoadDevices" class="btn btn-outline-primary" type="button">Load Detected Devices</button>
148
+ <button id="tuyaNodevApplyAc" class="btn btn-primary" type="button">Add / Update AC Override</button>
149
+ <button id="tuyaNodevRemoveAc" class="btn btn-outline-danger" type="button">Remove Selected AC Override</button>
150
+ </div>
151
+
152
+ <div id="tuyaNodevAcStatus" class="tuya-nodev-ac-status alert alert-secondary">
153
+ Load detected devices after the plugin has authenticated and discovered your Tuya devices at least once.
154
+ </div>
155
+
156
+ <div class="tuya-nodev-table-wrap">
157
+ <table class="tuya-nodev-table">
158
+ <thead>
159
+ <tr>
160
+ <th>Device</th>
161
+ <th>Device ID</th>
162
+ <th>Min</th>
163
+ <th>Max</th>
164
+ <th>Step</th>
165
+ <th></th>
166
+ </tr>
167
+ </thead>
168
+ <tbody id="tuyaNodevAcOverrideRows">
169
+ <tr><td colspan="6" class="tuya-nodev-small">No AC overrides configured.</td></tr>
170
+ </tbody>
171
+ </table>
172
+ </div>
173
+ </div>
174
+
90
175
  <script>
91
176
  (() => {
92
177
  const PLATFORM = 'TuyaNoDeveloperAccount';
93
178
  let currentConfig = null;
94
179
  let pollTimer = null;
95
180
  let isAuthenticated = false;
181
+ let detectedDevices = [];
96
182
 
97
183
  const $ = (id) => document.getElementById(id);
98
184
 
@@ -102,6 +188,12 @@
102
188
  el.textContent = message;
103
189
  }
104
190
 
191
+ function setAcStatus(message, type = 'secondary') {
192
+ const el = $('tuyaNodevAcStatus');
193
+ el.className = `tuya-nodev-ac-status alert alert-${type}`;
194
+ el.textContent = message;
195
+ }
196
+
105
197
  function getUserCode() {
106
198
  return $('tuyaNodevUserCode').value.trim();
107
199
  }
@@ -110,13 +202,20 @@
110
202
  return $('tuyaNodevName').value.trim() || 'Tuya without developer account';
111
203
  }
112
204
 
205
+ function clone(value) {
206
+ return value && typeof value === 'object' ? JSON.parse(JSON.stringify(value)) : value;
207
+ }
208
+
113
209
  function normaliseConfig(base) {
114
- const cfg = base && typeof base === 'object' ? JSON.parse(JSON.stringify(base)) : {};
210
+ const cfg = base && typeof base === 'object' ? clone(base) : {};
115
211
  cfg.platform = PLATFORM;
116
212
  cfg.name = getPlatformName();
117
213
  cfg.options = cfg.options && typeof cfg.options === 'object' ? cfg.options : {};
118
214
  cfg.options.userCode = getUserCode();
119
215
  cfg.options.projectType = '3';
216
+ if (!Array.isArray(cfg.options.deviceOverrides)) {
217
+ cfg.options.deviceOverrides = [];
218
+ }
120
219
  delete cfg.options.accessId;
121
220
  delete cfg.options.accessKey;
122
221
  delete cfg.options.username;
@@ -128,9 +227,15 @@
128
227
  return cfg;
129
228
  }
130
229
 
131
- async function syncConfigToUi() {
230
+ function ensureConfig() {
132
231
  currentConfig = normaliseConfig(currentConfig);
232
+ return currentConfig;
233
+ }
234
+
235
+ async function syncConfigToUi() {
236
+ ensureConfig();
133
237
  await homebridge.updatePluginConfig([currentConfig]);
238
+ renderAcOverrides();
134
239
  }
135
240
 
136
241
  function stopPolling() {
@@ -150,6 +255,195 @@
150
255
  if (homebridge.enableSaveButton) homebridge.enableSaveButton();
151
256
  }
152
257
 
258
+ function getDeviceName(id) {
259
+ const device = detectedDevices.find((item) => item.id === id);
260
+ return device ? device.name : '';
261
+ }
262
+
263
+ function getAcOverrides() {
264
+ const cfg = ensureConfig();
265
+ return (cfg.options.deviceOverrides || []).filter((item) => item && item.id && item.airConditioner && typeof item.airConditioner === 'object');
266
+ }
267
+
268
+ function isOnlyAcOverride(item) {
269
+ const keys = Object.keys(item || {}).filter((key) => item[key] !== undefined && item[key] !== null && item[key] !== '');
270
+ return keys.every((key) => ['id', 'airConditioner'].includes(key));
271
+ }
272
+
273
+ function renderAcOverrides() {
274
+ const tbody = $('tuyaNodevAcOverrideRows');
275
+ const overrides = getAcOverrides();
276
+ if (!overrides.length) {
277
+ tbody.innerHTML = '<tr><td colspan="6" class="tuya-nodev-small">No AC overrides configured.</td></tr>';
278
+ return;
279
+ }
280
+
281
+ tbody.innerHTML = overrides.map((override) => {
282
+ const ac = override.airConditioner || {};
283
+ const name = getDeviceName(override.id) || 'Unknown / not in detected cache';
284
+ return `
285
+ <tr>
286
+ <td>${escapeHtml(name)}</td>
287
+ <td><code>${escapeHtml(override.id)}</code></td>
288
+ <td>${escapeHtml(ac.minTemperature ?? '')} °C</td>
289
+ <td>${escapeHtml(ac.maxTemperature ?? '')} °C</td>
290
+ <td>${escapeHtml(ac.temperatureStep ?? '')} °C</td>
291
+ <td><button class="btn btn-sm btn-outline-secondary tuya-nodev-edit-ac" type="button" data-id="${escapeHtml(override.id)}">Edit</button></td>
292
+ </tr>`;
293
+ }).join('');
294
+
295
+ tbody.querySelectorAll('.tuya-nodev-edit-ac').forEach((button) => {
296
+ button.addEventListener('click', () => editAcOverride(button.getAttribute('data-id')));
297
+ });
298
+ }
299
+
300
+ function escapeHtml(value) {
301
+ return String(value ?? '')
302
+ .replace(/&/g, '&amp;')
303
+ .replace(/</g, '&lt;')
304
+ .replace(/>/g, '&gt;')
305
+ .replace(/"/g, '&quot;')
306
+ .replace(/'/g, '&#039;');
307
+ }
308
+
309
+ function parseNumberInput(id, label) {
310
+ const raw = $(id).value;
311
+ const value = Number(raw);
312
+ if (!Number.isFinite(value)) {
313
+ throw new Error(`${label} must be a number.`);
314
+ }
315
+ return value;
316
+ }
317
+
318
+ function populateDeviceSelect(devices) {
319
+ const select = $('tuyaNodevAcDevice');
320
+ const current = select.value;
321
+ if (!devices.length) {
322
+ select.innerHTML = '<option value="">No detected devices found</option>';
323
+ return;
324
+ }
325
+
326
+ select.innerHTML = '<option value="">Select a Tuya device...</option>' + devices.map((device) => {
327
+ const tag = device.likelyAirConditioner ? 'AC candidate' : (device.category || 'device');
328
+ const text = `${device.name} — ${tag} — ${device.id}`;
329
+ return `<option value="${escapeHtml(device.id)}">${escapeHtml(text)}</option>`;
330
+ }).join('');
331
+
332
+ if (current && devices.some((device) => device.id === current)) {
333
+ select.value = current;
334
+ }
335
+ }
336
+
337
+ async function loadDetectedDevices(showToast = true) {
338
+ try {
339
+ homebridge.showSpinner();
340
+ const res = await homebridge.request('/devices/list', {});
341
+ detectedDevices = Array.isArray(res.devices) ? res.devices : [];
342
+ populateDeviceSelect(detectedDevices);
343
+ renderAcOverrides();
344
+ const acCount = detectedDevices.filter((device) => device.likelyAirConditioner).length;
345
+ setAcStatus(`${res.message || 'Device list loaded.'} ${acCount ? `${acCount} AC candidate(s) listed first.` : ''}`.trim(), detectedDevices.length ? 'success' : 'warning');
346
+ if (showToast) homebridge.toast.success(`Loaded ${detectedDevices.length} detected Tuya device(s).`, 'Tuya');
347
+ } catch (e) {
348
+ setAcStatus(e.message || 'Failed to load detected devices.', 'danger');
349
+ if (showToast) homebridge.toast.error(e.message || 'Failed to load detected devices.', 'Tuya');
350
+ } finally {
351
+ homebridge.hideSpinner();
352
+ }
353
+ }
354
+
355
+ function editAcOverride(id) {
356
+ const override = getAcOverrides().find((item) => item.id === id);
357
+ if (!override) {
358
+ return;
359
+ }
360
+ const device = detectedDevices.find((item) => item.id === id);
361
+ if (!device) {
362
+ const select = $('tuyaNodevAcDevice');
363
+ const option = document.createElement('option');
364
+ option.value = id;
365
+ option.textContent = `${id} — not currently in detected device cache`;
366
+ select.appendChild(option);
367
+ }
368
+ $('tuyaNodevAcDevice').value = id;
369
+ $('tuyaNodevAcMin').value = override.airConditioner.minTemperature ?? 17;
370
+ $('tuyaNodevAcMax').value = override.airConditioner.maxTemperature ?? 31;
371
+ $('tuyaNodevAcStep').value = override.airConditioner.temperatureStep ?? 1;
372
+ setAcStatus(`Editing AC override for ${device?.name || id}.`, 'info');
373
+ }
374
+
375
+ async function addOrUpdateAcOverride() {
376
+ try {
377
+ const id = $('tuyaNodevAcDevice').value.trim();
378
+ if (!id) {
379
+ setAcStatus('Select a Tuya device first.', 'warning');
380
+ return;
381
+ }
382
+ const minTemperature = parseNumberInput('tuyaNodevAcMin', 'Minimum temperature');
383
+ const maxTemperature = parseNumberInput('tuyaNodevAcMax', 'Maximum temperature');
384
+ const temperatureStep = parseNumberInput('tuyaNodevAcStep', 'Temperature step');
385
+ if (temperatureStep <= 0) {
386
+ throw new Error('Temperature step must be greater than 0.');
387
+ }
388
+ if (minTemperature > maxTemperature) {
389
+ throw new Error('Minimum temperature cannot be greater than maximum temperature.');
390
+ }
391
+
392
+ const cfg = ensureConfig();
393
+ const overrides = cfg.options.deviceOverrides;
394
+ let override = overrides.find((item) => item && String(item.id || '').trim() === id);
395
+ if (!override) {
396
+ override = { id };
397
+ overrides.push(override);
398
+ }
399
+ override.id = id;
400
+ override.airConditioner = { minTemperature, maxTemperature, temperatureStep };
401
+
402
+ await syncConfigToUi();
403
+ const name = getDeviceName(id) || id;
404
+ setAcStatus(`AC override saved in plugin config for ${name}: ${minTemperature}–${maxTemperature} °C, step ${temperatureStep} °C. Click Save Configuration when ready.`, 'success');
405
+ homebridge.toast.success('AC temperature override added to config.', 'Tuya');
406
+ if (isAuthenticated) {
407
+ enableSaving();
408
+ }
409
+ } catch (e) {
410
+ setAcStatus(e.message || 'Failed to add AC override.', 'danger');
411
+ }
412
+ }
413
+
414
+ async function removeSelectedAcOverride() {
415
+ const id = $('tuyaNodevAcDevice').value.trim();
416
+ if (!id) {
417
+ setAcStatus('Select a Tuya device first.', 'warning');
418
+ return;
419
+ }
420
+
421
+ const cfg = ensureConfig();
422
+ const before = cfg.options.deviceOverrides.length;
423
+ cfg.options.deviceOverrides = cfg.options.deviceOverrides.flatMap((item) => {
424
+ if (!item || String(item.id || '').trim() !== id) {
425
+ return [item];
426
+ }
427
+ if (isOnlyAcOverride(item)) {
428
+ return [];
429
+ }
430
+ const copy = clone(item);
431
+ delete copy.airConditioner;
432
+ return [copy];
433
+ });
434
+
435
+ await syncConfigToUi();
436
+ if (cfg.options.deviceOverrides.length === before) {
437
+ setAcStatus('No AC override existed for the selected device.', 'warning');
438
+ } else {
439
+ setAcStatus('Selected AC override was removed from the plugin config. Click Save Configuration when ready.', 'success');
440
+ homebridge.toast.success('AC temperature override removed from config.', 'Tuya');
441
+ }
442
+ if (isAuthenticated) {
443
+ enableSaving();
444
+ }
445
+ }
446
+
153
447
  async function checkAuth(showSuccessToast = false) {
154
448
  const userCode = getUserCode();
155
449
  if (!userCode) {
@@ -260,15 +554,92 @@
260
554
  }
261
555
  }
262
556
 
557
+
558
+ function delay(ms) {
559
+ return new Promise((resolve) => setTimeout(resolve, ms));
560
+ }
561
+
562
+ async function withTimeout(promise, timeoutMs, timeoutMessage) {
563
+ let timer;
564
+ try {
565
+ return await Promise.race([
566
+ promise,
567
+ new Promise((_, reject) => {
568
+ timer = setTimeout(() => reject(new Error(timeoutMessage)), timeoutMs);
569
+ }),
570
+ ]);
571
+ } finally {
572
+ if (timer) {
573
+ clearTimeout(timer);
574
+ }
575
+ }
576
+ }
577
+
578
+ async function verifySavedConfig(userCode) {
579
+ const blocks = await withTimeout(
580
+ homebridge.getPluginConfig(),
581
+ 8000,
582
+ 'Configuration save timed out while verifying the saved plugin config.'
583
+ );
584
+ const block = Array.isArray(blocks) && blocks.length > 0 ? blocks[0] : blocks;
585
+ return !!(block && block.platform === PLATFORM && block.options && block.options.userCode === userCode);
586
+ }
587
+
263
588
  async function saveConfig() {
264
589
  if (!isAuthenticated) {
265
590
  setStatus('Scan and approve the QR code before saving.', 'warning');
266
591
  return;
267
592
  }
593
+
594
+ const userCode = getUserCode();
595
+ if (!userCode) {
596
+ setStatus('User Code is required before saving.', 'warning');
597
+ return;
598
+ }
599
+
268
600
  try {
269
601
  homebridge.showSpinner();
270
- await syncConfigToUi();
271
- await homebridge.savePluginConfig();
602
+ $('tuyaNodevSave').disabled = true;
603
+
604
+ await withTimeout(
605
+ syncConfigToUi(),
606
+ 8000,
607
+ 'Timed out while preparing the plugin configuration for saving.'
608
+ );
609
+
610
+ // Some Homebridge UI versions have been observed to save the config but never resolve
611
+ // savePluginConfig(), which leaves the custom UI spinner running forever. Timeout the
612
+ // UI call, then verify the saved config before showing the final result.
613
+ let saveTimedOut = false;
614
+ try {
615
+ await withTimeout(
616
+ homebridge.savePluginConfig(),
617
+ 15000,
618
+ 'Homebridge UI did not finish the save request in time.'
619
+ );
620
+ } catch (e) {
621
+ saveTimedOut = true;
622
+ }
623
+
624
+ let verified = false;
625
+ try {
626
+ await delay(750);
627
+ verified = await verifySavedConfig(userCode);
628
+ } catch (e) {
629
+ verified = false;
630
+ }
631
+
632
+ if (saveTimedOut && !verified) {
633
+ throw new Error('Save did not complete. The QR auth token is saved, but the plugin configuration could not be verified. Close this window, refresh Homebridge UI, and check whether the config was saved.');
634
+ }
635
+
636
+ if (saveTimedOut && verified) {
637
+ const message = 'Configuration appears to be saved, but Homebridge UI did not return a save confirmation. Close this settings window and restart Homebridge.';
638
+ homebridge.toast.success(message, 'Tuya');
639
+ setStatus(message, 'success');
640
+ return;
641
+ }
642
+
272
643
  homebridge.toast.success('Configuration saved. Restart Homebridge to load devices.', 'Tuya');
273
644
  setStatus('Configuration saved. Restart Homebridge to load devices.', 'success');
274
645
  } catch (e) {
@@ -276,6 +647,9 @@
276
647
  homebridge.toast.error(e.message || 'Failed to save configuration.', 'Tuya');
277
648
  } finally {
278
649
  homebridge.hideSpinner();
650
+ if (isAuthenticated) {
651
+ $('tuyaNodevSave').disabled = false;
652
+ }
279
653
  }
280
654
  }
281
655
 
@@ -286,12 +660,9 @@
286
660
  platform: PLATFORM,
287
661
  name: 'Tuya without developer account',
288
662
  mode: 'cloud',
289
- options: { projectType: '3' },
663
+ options: { projectType: '3', deviceOverrides: [] },
290
664
  };
291
- currentConfig.platform = PLATFORM;
292
- currentConfig.mode = 'cloud';
293
- currentConfig.options = currentConfig.options || {};
294
- currentConfig.options.projectType = '3';
665
+ ensureConfig();
295
666
  $('tuyaNodevName').value = currentConfig.name || 'Tuya without developer account';
296
667
  $('tuyaNodevUserCode').value = currentConfig.options?.userCode || '';
297
668
 
@@ -301,12 +672,10 @@
301
672
  const block = Array.isArray(data) ? data[0] : data;
302
673
  if (block && typeof block === 'object') {
303
674
  currentConfig = block;
304
- currentConfig.platform = PLATFORM;
305
- currentConfig.mode = 'cloud';
306
- currentConfig.options = currentConfig.options || {};
307
- currentConfig.options.projectType = '3';
675
+ ensureConfig();
308
676
  if (block.name) $('tuyaNodevName').value = block.name;
309
677
  if (block.options?.userCode) $('tuyaNodevUserCode').value = block.options.userCode;
678
+ renderAcOverrides();
310
679
  }
311
680
  });
312
681
 
@@ -320,8 +689,12 @@
320
689
  disableSaving();
321
690
  syncConfigToUi();
322
691
  });
692
+ $('tuyaNodevLoadDevices').addEventListener('click', () => loadDetectedDevices(true));
693
+ $('tuyaNodevApplyAc').addEventListener('click', addOrUpdateAcOverride);
694
+ $('tuyaNodevRemoveAc').addEventListener('click', removeSelectedAcOverride);
323
695
 
324
696
  await syncConfigToUi();
697
+ await loadDetectedDevices(false);
325
698
  if (getUserCode()) {
326
699
  await checkAuth(false);
327
700
  } else {
@@ -13,6 +13,161 @@ function normaliseUserCode(userCode) {
13
13
  return String(userCode || '').trim();
14
14
  }
15
15
 
16
+ function firstString(...values) {
17
+ for (const value of values) {
18
+ if (value === undefined || value === null) {
19
+ continue;
20
+ }
21
+ const text = String(value).trim();
22
+ if (text) {
23
+ return text;
24
+ }
25
+ }
26
+ return '';
27
+ }
28
+
29
+ function looksLikeAirConditioner(device) {
30
+ const haystack = [
31
+ device.name,
32
+ device.category,
33
+ device.productName,
34
+ device.productId,
35
+ device.model,
36
+ ].filter(Boolean).join(' ').toLowerCase();
37
+
38
+ return [
39
+ 'air conditioner',
40
+ 'airconditioner',
41
+ 'aircon',
42
+ 'a/c',
43
+ 'ac ',
44
+ ' ac',
45
+ 'clima',
46
+ 'climă',
47
+ 'aer conditionat',
48
+ 'aer condiționat',
49
+ 'hvac',
50
+ ].some((needle) => haystack.includes(needle))
51
+ || ['kt', 'wk', 'air_conditioner', 'airconditioner'].includes(String(device.category || '').toLowerCase());
52
+ }
53
+
54
+ function collectDevicesFromObject(root) {
55
+ const byId = new Map();
56
+
57
+ function addDevice(obj) {
58
+ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) {
59
+ return;
60
+ }
61
+
62
+ const id = firstString(
63
+ obj.id,
64
+ obj.devId,
65
+ obj.dev_id,
66
+ obj.deviceId,
67
+ obj.device_id,
68
+ obj.uid,
69
+ );
70
+
71
+ const name = firstString(
72
+ obj.name,
73
+ obj.deviceName,
74
+ obj.device_name,
75
+ obj.customName,
76
+ obj.custom_name,
77
+ obj.title,
78
+ );
79
+
80
+ if (!id || !name) {
81
+ return;
82
+ }
83
+
84
+ // Avoid adding automation scenes as selectable devices.
85
+ if (obj.scene_id || obj.sceneId || obj.rule_id || obj.ruleId) {
86
+ return;
87
+ }
88
+
89
+ const category = firstString(
90
+ obj.category,
91
+ obj.categoryCode,
92
+ obj.category_code,
93
+ obj.productCategory,
94
+ obj.product_category,
95
+ );
96
+
97
+ const productName = firstString(
98
+ obj.productName,
99
+ obj.product_name,
100
+ obj.product,
101
+ obj.productTitle,
102
+ );
103
+
104
+ const productId = firstString(
105
+ obj.productId,
106
+ obj.product_id,
107
+ obj.pid,
108
+ );
109
+
110
+ const model = firstString(obj.model, obj.modelId, obj.model_id);
111
+
112
+ const status = Array.isArray(obj.status) ? obj.status : [];
113
+ const statusCodes = status
114
+ .map((item) => item && typeof item === 'object' ? firstString(item.code) : '')
115
+ .filter(Boolean);
116
+
117
+ const schema = Array.isArray(obj.schema) ? obj.schema : Array.isArray(obj.schemas) ? obj.schemas : [];
118
+ const schemaCodes = schema
119
+ .map((item) => item && typeof item === 'object' ? firstString(item.code) : '')
120
+ .filter(Boolean);
121
+
122
+ const existing = byId.get(id) || {};
123
+ const merged = {
124
+ id,
125
+ name: existing.name || name,
126
+ category: existing.category || category || null,
127
+ productName: existing.productName || productName || null,
128
+ productId: existing.productId || productId || null,
129
+ model: existing.model || model || null,
130
+ online: typeof obj.online === 'boolean' ? obj.online : existing.online,
131
+ statusCodes: Array.from(new Set([...(existing.statusCodes || []), ...statusCodes])).sort(),
132
+ schemaCodes: Array.from(new Set([...(existing.schemaCodes || []), ...schemaCodes])).sort(),
133
+ };
134
+ merged.likelyAirConditioner = looksLikeAirConditioner(merged)
135
+ || merged.statusCodes.includes('temp_set')
136
+ || merged.schemaCodes.includes('temp_set');
137
+ merged.label = `${merged.name} (${merged.id})`;
138
+ byId.set(id, merged);
139
+ }
140
+
141
+ function walk(value) {
142
+ if (Array.isArray(value)) {
143
+ for (const item of value) {
144
+ walk(item);
145
+ }
146
+ return;
147
+ }
148
+ if (!value || typeof value !== 'object') {
149
+ return;
150
+ }
151
+
152
+ addDevice(value);
153
+
154
+ for (const child of Object.values(value)) {
155
+ if (child && typeof child === 'object') {
156
+ walk(child);
157
+ }
158
+ }
159
+ }
160
+
161
+ walk(root);
162
+
163
+ return Array.from(byId.values()).sort((a, b) => {
164
+ if (a.likelyAirConditioner !== b.likelyAirConditioner) {
165
+ return a.likelyAirConditioner ? -1 : 1;
166
+ }
167
+ return String(a.name).localeCompare(String(b.name));
168
+ });
169
+ }
170
+
16
171
  (async () => {
17
172
  const { HomebridgePluginUiServer, RequestError } = await import('@homebridge/plugin-ui-utils');
18
173
 
@@ -24,6 +179,7 @@ function normaliseUserCode(userCode) {
24
179
  this.onRequest('/qr/status', this.qrStatus.bind(this));
25
180
  this.onRequest('/auth/status', this.authStatus.bind(this));
26
181
  this.onRequest('/auth/clear', this.clearAuth.bind(this));
182
+ this.onRequest('/devices/list', this.listDevices.bind(this));
27
183
  this.ready();
28
184
  }
29
185
 
@@ -58,6 +214,63 @@ function normaliseUserCode(userCode) {
58
214
  return file;
59
215
  }
60
216
 
217
+ async listDevices() {
218
+ const persistDir = path.join(this.homebridgeStoragePath, 'persist');
219
+ let entries;
220
+ try {
221
+ entries = await fs.promises.readdir(persistDir, { withFileTypes: true });
222
+ } catch (err) {
223
+ if (err && err.code === 'ENOENT') {
224
+ return { devices: [], files: [], message: 'No Homebridge persist directory found yet. Authenticate and restart Homebridge once so the plugin can save a device list.' };
225
+ }
226
+ throw err;
227
+ }
228
+
229
+ const candidates = [];
230
+ for (const entry of entries) {
231
+ if (!entry.isFile()) {
232
+ continue;
233
+ }
234
+ if (!/^TuyaDeviceList.*\.json$/i.test(entry.name)) {
235
+ continue;
236
+ }
237
+ const file = path.join(persistDir, entry.name);
238
+ const stat = await fs.promises.stat(file);
239
+ candidates.push({ file, mtimeMs: stat.mtimeMs });
240
+ }
241
+
242
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
243
+
244
+ const allDevices = new Map();
245
+ const errors = [];
246
+ for (const candidate of candidates) {
247
+ try {
248
+ const data = JSON.parse(await fs.promises.readFile(candidate.file, 'utf8'));
249
+ for (const device of collectDevicesFromObject(data)) {
250
+ if (!allDevices.has(device.id)) {
251
+ allDevices.set(device.id, device);
252
+ }
253
+ }
254
+ } catch (err) {
255
+ errors.push({ file: candidate.file, message: err.message });
256
+ }
257
+ }
258
+
259
+ const devices = Array.from(allDevices.values()).sort((a, b) => {
260
+ if (a.likelyAirConditioner !== b.likelyAirConditioner) {
261
+ return a.likelyAirConditioner ? -1 : 1;
262
+ }
263
+ return String(a.name).localeCompare(String(b.name));
264
+ });
265
+
266
+ return {
267
+ devices,
268
+ files: candidates.map((item) => item.file),
269
+ errors,
270
+ message: devices.length ? `Loaded ${devices.length} Tuya device(s) from Homebridge persist cache.` : 'No devices found in TuyaDeviceList cache yet. Authenticate and restart Homebridge once, then reopen this settings page.',
271
+ };
272
+ }
273
+
61
274
  async authStatus(payload = {}) {
62
275
  const userCode = normaliseUserCode(payload.userCode);
63
276
  if (!userCode) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "homebridge-tuya-without-developer-account",
3
3
  "displayName": "Tuya without developer account for Homebridge",
4
- "version": "1.0.2",
4
+ "version": "1.0.6",
5
5
  "description": "Homebridge plugin for Tuya and Smart Life devices using QR cloud authentication without a Tuya IoT developer account.",
6
6
  "license": "MIT",
7
7
  "author": "Kosztyk",