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

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.1
4
+
5
+ - Fixes repeated `code=-9999999, msg=sign invalid` errors caused by incomplete token expiry handling and non-persistent token refreshes.
6
+ - Saves refreshed Tuya QR access/refresh tokens immediately to the Homebridge storage auth file.
7
+ - Retries a signed Tuya request once after forcing a token refresh when Tuya returns `sign invalid`.
8
+ - Accepts both snake_case and camelCase token fields returned by Tuya QR login/refresh responses.
9
+ - Adds the plugin icon to the custom Homebridge settings UI and README.
10
+
11
+ ## 1.0.0
12
+
13
+ - Initial QR-only release.
14
+
3
15
  ## 1.0.0
4
16
 
5
17
  - 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**.
@@ -231,3 +235,14 @@ This project is based on the Homebridge Tuya plugin codebase and adapts the Tuya
231
235
  ## License
232
236
 
233
237
  MIT
238
+
239
+
240
+ ## Token refresh and sign invalid errors
241
+
242
+ 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:
243
+
244
+ ```text
245
+ [Tuya QR] Fetching home list failed. code=-9999999, msg=sign invalid
246
+ ```
247
+
248
+ 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
@@ -130,10 +130,17 @@ class TuyaPlatform {
130
130
  try {
131
131
  const raw = await fs.promises.readFile(file, "utf8");
132
132
  const data = JSON.parse(raw);
133
- if (!data.userCode || !data.endpoint || !data.terminalId || !data.tokenInfo?.access_token || !data.tokenInfo?.refresh_token) {
133
+ const tokenInfo = data.tokenInfo || {};
134
+ if (!data.userCode || !data.endpoint || !data.terminalId || !(tokenInfo.access_token || tokenInfo.accessToken) || !(tokenInfo.refresh_token || tokenInfo.refreshToken)) {
134
135
  this.log.warn("[Tuya QR] Existing auth file is incomplete. Clear authentication in the plugin settings and scan again.");
135
136
  return undefined;
136
137
  }
138
+ data.tokenInfo = {
139
+ ...tokenInfo,
140
+ access_token: tokenInfo.access_token || tokenInfo.accessToken,
141
+ refresh_token: tokenInfo.refresh_token || tokenInfo.refreshToken,
142
+ expire_time: tokenInfo.expire_time || tokenInfo.expireTime || tokenInfo.expire || 7200,
143
+ };
137
144
  return data;
138
145
  } catch {
139
146
  return undefined;
@@ -219,14 +226,22 @@ class TuyaPlatform {
219
226
  return undefined;
220
227
  }
221
228
 
222
- const api = new TuyaHACloudAPI(userCode, authData.terminalId, authData.endpoint, authData.tokenInfo, this.log, debugMode);
229
+ const api = new TuyaHACloudAPI(userCode, authData.terminalId, authData.endpoint, authData.tokenInfo, this.log, debugMode, async (tokenInfo) => {
230
+ await this.writeAuthData(userCode, {
231
+ ...authData,
232
+ endpoint: api.endpoint,
233
+ tokenInfo,
234
+ savedAt: Date.now(),
235
+ refreshedAt: Date.now(),
236
+ });
237
+ });
223
238
  const deviceManager = new TuyaHADeviceManager(api, debugMode);
224
239
 
225
240
  this.log.info("[Tuya QR] Fetching home list.");
226
241
  const res = await deviceManager.getHomeList();
227
242
  if (res.success === false) {
228
243
  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.");
244
+ this.log.error("[Tuya QR] Token refresh was attempted automatically. If this continues, clear authentication in the plugin settings and scan again.");
230
245
  return undefined;
231
246
  }
232
247
 
@@ -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.1",
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": {