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 +12 -0
- package/README.md +15 -0
- package/dist/cloud/api/TuyaHACloudAPI.js +61 -18
- package/dist/platform.js +18 -3
- package/homebridge-ui/public/homebridge-tuya.png +0 -0
- package/homebridge-ui/public/index.html +9 -1
- package/homebridge-ui/server.js +12 -5
- package/package.json +2 -2
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
|
-
|
|
35
|
-
this.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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.
|
|
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 ||
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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]
|
|
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
|
|
|
Binary file
|
|
@@ -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
|
-
<
|
|
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>
|
package/homebridge-ui/server.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
5
|
-
"description": "Homebridge plugin for Tuya and Smart Life devices using
|
|
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": {
|