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

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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.2
4
+
5
+ - 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.
6
+ - Duplicate device override IDs are now ignored safely, keeping the first valid entry.
7
+ - Invalid or duplicate schema override entries are now skipped with warnings instead of blocking Homebridge startup.
8
+
9
+ ## 1.0.1
10
+
11
+ - Fixes repeated `code=-9999999, msg=sign invalid` errors caused by incomplete token expiry handling and non-persistent token refreshes.
12
+ - Saves refreshed Tuya QR access/refresh tokens immediately to the Homebridge storage auth file.
13
+ - Retries a signed Tuya request once after forcing a token refresh when Tuya returns `sign invalid`.
14
+ - Accepts both snake_case and camelCase token fields returned by Tuya QR login/refresh responses.
15
+ - Adds the plugin icon to the custom Homebridge settings UI and README.
16
+
17
+ ## 1.0.0
18
+
19
+ - Initial QR-only release.
20
+
3
21
  ## 1.0.0
4
22
 
5
23
  - Renamed plugin to **Tuya without developer account for Homebridge**.
package/README.md CHANGED
@@ -1,3 +1,7 @@
1
+ <p align="center">
2
+ <img src="./homebridge-ui/public/homebridge-tuya.png" width="96" alt="Tuya without developer account for Homebridge" />
3
+ </p>
4
+
1
5
  # Tuya without developer account for Homebridge
2
6
 
3
7
  A Homebridge platform plugin for Tuya and Smart Life devices that uses **Home Assistant-style Tuya QR Cloud Authentication**.
@@ -188,6 +192,11 @@ Use `
188
192
 
189
193
  ## Troubleshooting
190
194
 
195
+ ### Plugin starts from cache only and logs `Each device override must include an "id"`
196
+
197
+ Version 1.0.2 and newer no longer abort startup for empty override rows created by the Homebridge UI. Invalid override entries are skipped with a warning. If you still see old warnings, remove empty rows from the Device Overrides section in the plugin settings and restart Homebridge.
198
+
199
+
191
200
  ### The QR code does not appear
192
201
 
193
202
  Make sure you opened the settings for this plugin, not another Tuya plugin. The plugin name should be:
@@ -231,3 +240,14 @@ This project is based on the Homebridge Tuya plugin codebase and adapts the Tuya
231
240
  ## License
232
241
 
233
242
  MIT
243
+
244
+
245
+ ## Token refresh and sign invalid errors
246
+
247
+ Version 1.0.1 and later persist refreshed Tuya QR tokens back to the Homebridge storage auth file. This prevents repeated startup failures such as:
248
+
249
+ ```text
250
+ [Tuya QR] Fetching home list failed. code=-9999999, msg=sign invalid
251
+ ```
252
+
253
+ 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.
@@ -13,12 +13,13 @@ exports.TUYA_HA_CLIENT_ID = 'HA_3y9q4ak7g4ephrvke';
13
13
  exports.TUYA_HA_SCHEMA = 'haauthorize';
14
14
  exports.TUYA_HA_QR_ENDPOINT = 'https://apigw.iotbing.com';
15
15
  class TuyaHACloudAPI {
16
- constructor(userCode, terminalId, endpoint, tokenInfo, log = console, debug = false) {
16
+ constructor(userCode, terminalId, endpoint, tokenInfo, log = console, debug = false, tokenUpdateListener) {
17
17
  this.userCode = userCode;
18
18
  this.terminalId = terminalId;
19
19
  this.endpoint = endpoint;
20
20
  this.log = log;
21
21
  this.debug = debug;
22
+ this.tokenUpdateListener = tokenUpdateListener;
22
23
  this.tokenInfo = {
23
24
  access_token: '',
24
25
  refresh_token: '',
@@ -31,23 +32,49 @@ class TuyaHACloudAPI {
31
32
  this.setTokenInfo(tokenInfo);
32
33
  }
33
34
  }
34
- setTokenInfo(tokenInfo) {
35
- this.tokenInfo = {
36
- access_token: tokenInfo.access_token,
37
- refresh_token: tokenInfo.refresh_token,
38
- uid: tokenInfo.uid,
39
- expire: (tokenInfo.t || Date.now()) + (tokenInfo.expire_time || 0) * 1000,
35
+ setTokenUpdateListener(listener) {
36
+ this.tokenUpdateListener = listener;
37
+ }
38
+ normaliseTokenInfo(tokenInfo = {}) {
39
+ const now = Date.now();
40
+ const expireTimeRaw = tokenInfo.expire_time ?? tokenInfo.expireTime ?? tokenInfo.expire;
41
+ const expireTime = Number.isFinite(Number(expireTimeRaw)) && Number(expireTimeRaw) > 0
42
+ ? Number(expireTimeRaw)
43
+ : 7200;
44
+ return {
45
+ access_token: tokenInfo.access_token || tokenInfo.accessToken || this.tokenInfo.access_token || '',
46
+ refresh_token: tokenInfo.refresh_token || tokenInfo.refreshToken || this.tokenInfo.refresh_token || '',
47
+ uid: tokenInfo.uid || this.tokenInfo.uid || '',
48
+ expire: Number(tokenInfo.expireAt || 0) > now
49
+ ? Number(tokenInfo.expireAt)
50
+ : (Number(tokenInfo.t || 0) > 0 ? Number(tokenInfo.t) : now) + expireTime * 1000,
40
51
  };
41
52
  }
53
+ setTokenInfo(tokenInfo) {
54
+ this.tokenInfo = this.normaliseTokenInfo(tokenInfo);
55
+ }
42
56
  exportTokenInfo() {
43
57
  return {
44
58
  t: Date.now(),
45
59
  uid: this.tokenInfo.uid,
46
60
  expire_time: Math.max(0, Math.floor((this.tokenInfo.expire - Date.now()) / 1000)),
61
+ expireAt: this.tokenInfo.expire,
47
62
  access_token: this.tokenInfo.access_token,
48
63
  refresh_token: this.tokenInfo.refresh_token,
49
64
  };
50
65
  }
66
+ async notifyTokenUpdate() {
67
+ if (typeof this.tokenUpdateListener !== 'function') {
68
+ return;
69
+ }
70
+ try {
71
+ await this.tokenUpdateListener(this.exportTokenInfo());
72
+ }
73
+ catch (error) {
74
+ const msg = error instanceof Error ? error.message : String(error);
75
+ this.log.warn('Could not persist refreshed Tuya token: %s', msg);
76
+ }
77
+ }
51
78
  isLogin() {
52
79
  return this.tokenInfo.access_token.length > 0;
53
80
  }
@@ -93,43 +120,52 @@ class TuyaHACloudAPI {
93
120
  this.log.debug('HA raw response: %s', JSON.stringify(res));
94
121
  return res;
95
122
  }
96
- async refreshAccessTokenIfNeed() {
123
+ async refreshAccessTokenIfNeed(force = false) {
97
124
  if (!this.isLogin()) {
98
- return;
125
+ return false;
99
126
  }
100
- if (!this.isTokenExpired()) {
101
- return;
127
+ if (!force && !this.isTokenExpired()) {
128
+ return false;
102
129
  }
103
130
  if (this.refreshTokenInProgress) {
104
- return;
131
+ return false;
105
132
  }
106
133
  this.refreshTokenInProgress = true;
107
134
  try {
108
135
  this.log.info('Refreshing Tuya Home Assistant QR access token.');
109
- const response = await this.get(`/v1.0/m/token/${this.tokenInfo.refresh_token}`);
136
+ const response = await this.request('GET', `/v1.0/m/token/${this.tokenInfo.refresh_token}`, null, null, { skipRefresh: true, skipSignInvalidRetry: true });
110
137
  if (response && response.success) {
111
138
  const result = response.result || {};
112
139
  const tokenInfo = {
113
140
  t: response.t || Date.now(),
114
- expire_time: result.expireTime || result.expire_time || 0,
141
+ expire_time: result.expireTime || result.expire_time || result.expire || 7200,
115
142
  uid: result.uid,
116
143
  access_token: result.accessToken || result.access_token,
117
- refresh_token: result.refreshToken || result.refresh_token,
144
+ refresh_token: result.refreshToken || result.refresh_token || this.tokenInfo.refresh_token,
118
145
  };
119
146
  this.setTokenInfo(tokenInfo);
147
+ await this.notifyTokenUpdate();
148
+ this.log.info('Tuya Home Assistant QR access token refreshed and saved.');
149
+ return true;
120
150
  }
151
+ this.log.warn('Refresh Tuya access token failed. code=%s, msg=%s', response?.code, response?.msg);
152
+ return false;
121
153
  }
122
154
  catch (error) {
123
155
  const msg = error instanceof Error ? error.message : String(error);
124
156
  this.log.error('Failed to refresh Tuya access token: %s', msg);
157
+ return false;
125
158
  }
126
159
  finally {
127
160
  this.refreshTokenInProgress = false;
128
161
  }
129
162
  }
130
- async request(method, path, params, body) {
131
- if (!this.refreshTokenInProgress) {
132
- await this.refreshAccessTokenIfNeed();
163
+ isSignInvalidResponse(res) {
164
+ return res && res.success === false && (String(res.code) === '-9999999' || String(res.msg || '').toLowerCase().includes('sign invalid'));
165
+ }
166
+ async request(method, path, params, body, requestOptions = {}) {
167
+ if (!requestOptions.skipRefresh && !this.refreshTokenInProgress) {
168
+ await this.refreshAccessTokenIfNeed(false);
133
169
  }
134
170
  const rid = (0, util_1.generateUUID)();
135
171
  const sid = '';
@@ -199,6 +235,13 @@ class TuyaHACloudAPI {
199
235
  req.end();
200
236
  }), { retriesMax: 5, interval: 300, exponential: true, factor: 2, jitter: 100 });
201
237
  this.log.debug('HA encrypted response: %s', JSON.stringify(res));
238
+ if (!requestOptions.skipSignInvalidRetry && this.isSignInvalidResponse(res)) {
239
+ this.log.warn('Tuya returned sign invalid. Forcing token refresh and retrying request once.');
240
+ const refreshed = await this.refreshAccessTokenIfNeed(true);
241
+ if (refreshed) {
242
+ return this.request(method, path, params, body, { ...requestOptions, skipSignInvalidRetry: true });
243
+ }
244
+ }
202
245
  return res;
203
246
  }
204
247
  async get(path, params) {
package/dist/platform.js CHANGED
@@ -68,24 +68,45 @@ class TuyaPlatform {
68
68
  if (!this.options.deviceOverrides) {
69
69
  return true;
70
70
  }
71
- const idMap = new Map();
71
+ if (!Array.isArray(this.options.deviceOverrides)) {
72
+ this.log.warn('[Tuya QR] Ignoring invalid deviceOverrides value because it is not an array.');
73
+ this.options.deviceOverrides = [];
74
+ return true;
75
+ }
76
+
77
+ const validOverrides = [];
78
+ const seenIds = new Set();
79
+ let skippedMissingId = 0;
80
+ let skippedDuplicateId = 0;
81
+
72
82
  for (const item of this.options.deviceOverrides) {
73
- if (!item || !item.id) {
74
- this.log.error('[Tuya QR] Each device override must include an "id".');
75
- return false;
83
+ if (!item || typeof item !== 'object') {
84
+ skippedMissingId++;
85
+ continue;
76
86
  }
77
- if (idMap.has(item.id)) {
78
- idMap.get(item.id).push(item);
79
- } else {
80
- idMap.set(item.id, [item]);
87
+ const id = String(item.id || '').trim();
88
+ if (!id) {
89
+ skippedMissingId++;
90
+ continue;
81
91
  }
82
- }
83
- for (const items of idMap.values()) {
84
- if (items.length > 1) {
85
- this.log.error('[Tuya QR] "deviceOverrides" conflict, "id" must be unique: %o.', items);
86
- return false;
92
+ if (seenIds.has(id)) {
93
+ skippedDuplicateId++;
94
+ this.log.warn('[Tuya QR] Ignoring duplicate device override for id "%s". Keeping the first one.', id);
95
+ continue;
87
96
  }
97
+ item.id = id;
98
+ seenIds.add(id);
99
+ validOverrides.push(item);
100
+ }
101
+
102
+ if (skippedMissingId > 0) {
103
+ this.log.warn('[Tuya QR] Ignored %d invalid device override(s) without an "id". QR cloud startup will continue.', skippedMissingId);
88
104
  }
105
+ if (skippedDuplicateId > 0) {
106
+ this.log.warn('[Tuya QR] Ignored %d duplicate device override(s). QR cloud startup will continue.', skippedDuplicateId);
107
+ }
108
+
109
+ this.options.deviceOverrides = validOverrides;
89
110
  return true;
90
111
  }
91
112
 
@@ -97,20 +118,43 @@ class TuyaPlatform {
97
118
  if (!deviceOverride.schema) {
98
119
  continue;
99
120
  }
100
- const idMap = new Map();
121
+ if (!Array.isArray(deviceOverride.schema)) {
122
+ this.log.warn('[Tuya QR] Ignoring invalid schema override for device id "%s" because schema is not an array.', deviceOverride.id);
123
+ deviceOverride.schema = undefined;
124
+ continue;
125
+ }
126
+ const validSchema = [];
127
+ const seenCodes = new Set();
128
+ let skippedMissingCode = 0;
129
+ let skippedDuplicateCode = 0;
130
+
101
131
  for (const item of deviceOverride.schema) {
102
- if (idMap.has(item.code)) {
103
- idMap.get(item.code).push(item);
104
- } else {
105
- idMap.set(item.code, [item]);
132
+ if (!item || typeof item !== 'object') {
133
+ skippedMissingCode++;
134
+ continue;
106
135
  }
107
- }
108
- for (const items of idMap.values()) {
109
- if (items.length > 1) {
110
- this.log.error('[Tuya QR] "schema" conflict, "code" must be unique: %o.', items);
111
- return false;
136
+ const code = String(item.code || '').trim();
137
+ if (!code) {
138
+ skippedMissingCode++;
139
+ continue;
112
140
  }
141
+ if (seenCodes.has(code)) {
142
+ skippedDuplicateCode++;
143
+ this.log.warn('[Tuya QR] Ignoring duplicate schema override code "%s" for device id "%s". Keeping the first one.', code, deviceOverride.id);
144
+ continue;
145
+ }
146
+ item.code = code;
147
+ seenCodes.add(code);
148
+ validSchema.push(item);
149
+ }
150
+
151
+ if (skippedMissingCode > 0) {
152
+ this.log.warn('[Tuya QR] Ignored %d invalid schema override(s) without a "code" for device id "%s".', skippedMissingCode, deviceOverride.id);
113
153
  }
154
+ if (skippedDuplicateCode > 0) {
155
+ this.log.warn('[Tuya QR] Ignored %d duplicate schema override(s) for device id "%s".', skippedDuplicateCode, deviceOverride.id);
156
+ }
157
+ deviceOverride.schema = validSchema;
114
158
  }
115
159
  return true;
116
160
  }
@@ -130,10 +174,17 @@ class TuyaPlatform {
130
174
  try {
131
175
  const raw = await fs.promises.readFile(file, "utf8");
132
176
  const data = JSON.parse(raw);
133
- if (!data.userCode || !data.endpoint || !data.terminalId || !data.tokenInfo?.access_token || !data.tokenInfo?.refresh_token) {
177
+ const tokenInfo = data.tokenInfo || {};
178
+ if (!data.userCode || !data.endpoint || !data.terminalId || !(tokenInfo.access_token || tokenInfo.accessToken) || !(tokenInfo.refresh_token || tokenInfo.refreshToken)) {
134
179
  this.log.warn("[Tuya QR] Existing auth file is incomplete. Clear authentication in the plugin settings and scan again.");
135
180
  return undefined;
136
181
  }
182
+ data.tokenInfo = {
183
+ ...tokenInfo,
184
+ access_token: tokenInfo.access_token || tokenInfo.accessToken,
185
+ refresh_token: tokenInfo.refresh_token || tokenInfo.refreshToken,
186
+ expire_time: tokenInfo.expire_time || tokenInfo.expireTime || tokenInfo.expire || 7200,
187
+ };
137
188
  return data;
138
189
  } catch {
139
190
  return undefined;
@@ -219,14 +270,22 @@ class TuyaPlatform {
219
270
  return undefined;
220
271
  }
221
272
 
222
- const api = new TuyaHACloudAPI(userCode, authData.terminalId, authData.endpoint, authData.tokenInfo, this.log, debugMode);
273
+ const api = new TuyaHACloudAPI(userCode, authData.terminalId, authData.endpoint, authData.tokenInfo, this.log, debugMode, async (tokenInfo) => {
274
+ await this.writeAuthData(userCode, {
275
+ ...authData,
276
+ endpoint: api.endpoint,
277
+ tokenInfo,
278
+ savedAt: Date.now(),
279
+ refreshedAt: Date.now(),
280
+ });
281
+ });
223
282
  const deviceManager = new TuyaHADeviceManager(api, debugMode);
224
283
 
225
284
  this.log.info("[Tuya QR] Fetching home list.");
226
285
  const res = await deviceManager.getHomeList();
227
286
  if (res.success === false) {
228
287
  this.log.error(`[Tuya QR] Fetching home list failed. code=${res.code}, msg=${res.msg}`);
229
- this.log.error("[Tuya QR] If the token is expired or invalid, clear authentication in the plugin settings and scan again.");
288
+ this.log.error("[Tuya QR] Token refresh was attempted automatically. If this continues, clear authentication in the plugin settings and scan again.");
230
289
  return undefined;
231
290
  }
232
291
 
@@ -1,4 +1,11 @@
1
1
  <style>
2
+ .tuya-nodev-logo {
3
+ display: block;
4
+ width: 72px;
5
+ height: 72px;
6
+ margin: 0 auto 12px;
7
+ border-radius: 16px;
8
+ }
2
9
  .tuya-nodev-card {
3
10
  border: 1px solid rgba(127, 127, 127, 0.25);
4
11
  border-radius: 12px;
@@ -45,7 +52,8 @@
45
52
  </style>
46
53
 
47
54
  <div class="tuya-nodev-card">
48
- <div class="tuya-nodev-title">Tuya QR Cloud Authentication</div>
55
+ <img class="tuya-nodev-logo" src="./homebridge-tuya.png" alt="Tuya without developer account logo">
56
+ <div class="tuya-nodev-title" style="text-align:center;">Tuya QR Cloud Authentication</div>
49
57
  <p class="tuya-nodev-small mb-3">
50
58
  This plugin connects Tuya / Smart Life devices to Homebridge using the same QR authorization style used by the official Home Assistant Tuya integration. It does not need a Tuya IoT developer account, Access ID, Access Secret, cloud project, username, or password.
51
59
  </p>
@@ -35,9 +35,16 @@ function normaliseUserCode(userCode) {
35
35
  try {
36
36
  const raw = await fs.promises.readFile(this.getAuthFile(userCode), 'utf8');
37
37
  const data = JSON.parse(raw);
38
- if (!data.userCode || !data.endpoint || !data.terminalId || !data.tokenInfo?.access_token || !data.tokenInfo?.refresh_token) {
38
+ const tokenInfo = data.tokenInfo || {};
39
+ if (!data.userCode || !data.endpoint || !data.terminalId || !(tokenInfo.access_token || tokenInfo.accessToken) || !(tokenInfo.refresh_token || tokenInfo.refreshToken)) {
39
40
  return null;
40
41
  }
42
+ data.tokenInfo = {
43
+ ...tokenInfo,
44
+ access_token: tokenInfo.access_token || tokenInfo.accessToken,
45
+ refresh_token: tokenInfo.refresh_token || tokenInfo.refreshToken,
46
+ expire_time: tokenInfo.expire_time || tokenInfo.expireTime || tokenInfo.expire || 7200,
47
+ };
41
48
  return data;
42
49
  } catch {
43
50
  return null;
@@ -178,14 +185,14 @@ function normaliseUserCode(userCode) {
178
185
  const info = loginResponse.result || {};
179
186
  const authData = {
180
187
  userCode,
181
- terminalId: info.terminal_id,
188
+ terminalId: info.terminal_id || info.terminalId,
182
189
  endpoint: info.endpoint,
183
190
  tokenInfo: {
184
191
  t: loginResponse.t || info.t || Date.now(),
185
192
  uid: info.uid,
186
- expire_time: info.expire_time,
187
- access_token: info.access_token,
188
- refresh_token: info.refresh_token,
193
+ expire_time: info.expire_time || info.expireTime || info.expire || 7200,
194
+ access_token: info.access_token || info.accessToken,
195
+ refresh_token: info.refresh_token || info.refreshToken,
189
196
  },
190
197
  username: info.username,
191
198
  savedAt: Date.now(),
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "homebridge-tuya-without-developer-account",
3
3
  "displayName": "Tuya without developer account for Homebridge",
4
- "version": "1.0.0",
5
- "description": "Homebridge plugin for Tuya and Smart Life devices using Home Assistant-style QR cloud authentication, without a Tuya IoT developer account.",
4
+ "version": "1.0.2",
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",
8
8
  "repository": {