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 +28 -0
- package/README.md +64 -2
- package/SUPPORTED_DEVICES.md +10 -0
- package/config.schema.json +29 -0
- package/dist/platform.js +28 -0
- package/dist/shared/accessories/AirConditionerAccessory.js +41 -16
- package/dist/shared/accessories/DimmerAccessory.js +23 -2
- package/homebridge-ui/public/index.html +387 -14
- package/homebridge-ui/server.js +213 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
+
|
package/SUPPORTED_DEVICES.md
CHANGED
|
@@ -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
|
+
|
package/config.schema.json
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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' ?
|
|
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
|
-
|
|
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, '&')
|
|
303
|
+
.replace(/</g, '<')
|
|
304
|
+
.replace(/>/g, '>')
|
|
305
|
+
.replace(/"/g, '"')
|
|
306
|
+
.replace(/'/g, ''');
|
|
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
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/homebridge-ui/server.js
CHANGED
|
@@ -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.
|
|
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",
|