homebridge-ttlock-accesscode 2.0.0-beta.7 → 2.0.0
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/README.md +69 -27
- package/config.schema.json +5 -4
- package/dist/api/ttlockApi.d.ts +38 -2
- package/dist/api/ttlockApi.js +222 -96
- package/dist/api/ttlockApi.js.map +1 -1
- package/dist/api/usageTracker.d.ts +1 -1
- package/dist/api/usageTracker.js +27 -13
- package/dist/api/usageTracker.js.map +1 -1
- package/dist/config.js +3 -3
- package/dist/config.js.map +1 -1
- package/dist/devices/accessoryInformation.js +4 -2
- package/dist/devices/accessoryInformation.js.map +1 -1
- package/dist/devices/baseDevice.d.ts +12 -8
- package/dist/devices/baseDevice.js +164 -67
- package/dist/devices/baseDevice.js.map +1 -1
- package/dist/devices/create.js +2 -15
- package/dist/devices/create.js.map +1 -1
- package/dist/devices/descriptorHelpers.js +12 -15
- package/dist/devices/descriptorHelpers.js.map +1 -1
- package/dist/devices/deviceManager.d.ts +3 -2
- package/dist/devices/deviceManager.js +28 -4
- package/dist/devices/deviceManager.js.map +1 -1
- package/dist/devices/deviceTypes.d.ts +6 -4
- package/dist/devices/homekitLock.d.ts +15 -0
- package/dist/devices/homekitLock.js +148 -98
- package/dist/devices/homekitLock.js.map +1 -1
- package/dist/platform.d.ts +3 -0
- package/dist/platform.js +71 -14
- package/dist/platform.js.map +1 -1
- package/dist/utils.js +6 -2
- package/dist/utils.js.map +1 -1
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -19,37 +19,79 @@
|
|
|
19
19
|
<a href="https://github.com/sponsors/ZeliardM"><img src="https://img.shields.io/badge/donate-github-orange" alt="donate github"></a>
|
|
20
20
|
</p>
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
This is a [Homebridge](https://github.com/homebridge/homebridge) plug-in based for integrating TTLock smart locks with the TTLock Cloud API.
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
>HomeKey Support is not physically possible with TTLock Readers, but Access Code Features are working in HomeKit.
|
|
24
|
+
This plug-in lets you control TTLock locks in the Apple Home app with lock/unlock status, control, and access code management if supported.
|
|
26
25
|
|
|
27
|
-
|
|
26
|
+
## Requirements
|
|
27
|
+
- Homebridge Supported Versions: 1.8.0 and 2.0.0-beta.0 or later.
|
|
28
|
+
- Node.js Supported Versions: 20, 22, and 24.
|
|
29
|
+
- TTLock Smart Lock.
|
|
30
|
+
- TTLock Gateway for non-Wi-Fi locks.
|
|
31
|
+
- Remote Unlock enabled in the TTLock mobile app.
|
|
32
|
+
- TTLock Open API account with an approved OAUTH2.0 App.
|
|
28
33
|
|
|
29
|
-
|
|
34
|
+
## Current Supported and Tested Devices
|
|
35
|
+
- I have tested this plug-in with a G2 Gateway setup and TTLock lock access code features in Apple Home. More lock models and gateway setups are expected to work, but may vary by TTLock firmware and account setup.
|
|
30
36
|
|
|
31
37
|
## Features
|
|
32
|
-
|
|
33
38
|
- Get the status of your TTLock devices.
|
|
34
39
|
- Lock and unlock your TTLock devices.
|
|
35
|
-
- Manage passcodes for your TTLock devices.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
40
|
+
- Manage passcodes for your TTLock devices in Apple Home.
|
|
41
|
+
- Automatic API usage protection with adaptive polling.
|
|
42
|
+
- Periodic discovery and offline recovery handling.
|
|
43
|
+
|
|
44
|
+
## Installation
|
|
45
|
+
- Install from the Homebridge UI or with npm.
|
|
46
|
+
- After installing, configure your TTLock credentials and API App values, then restart Homebridge.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
npm install -g homebridge-ttlock-accesscode
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Configuration Notes
|
|
53
|
+
- Create an account in the [TTLock Cloud API](https://euopen.ttlock.com/register)
|
|
54
|
+
- Create your OAUTH2.0 App, approval may take a few days.
|
|
55
|
+
- Use your TTLock mobile app username/password and OAUTH2.0 App client_id and client_secret in the plugin settings.
|
|
56
|
+
- For non-Wi-Fi locks, make sure your gateway is online and near the lock.
|
|
57
|
+
- The default settings are tuned for TTLock's monthly API limits.
|
|
58
|
+
- Polling is automatically slowed when monthly allowance gets lower.
|
|
59
|
+
|
|
60
|
+
## Access Code Notes
|
|
61
|
+
- Access code support is exposed through the Apple Home App.
|
|
62
|
+
- Existing passcodes are loaded from TTLock and mapped into the Apple Home App.
|
|
63
|
+
- Add/Delete/List/Read flows are handled through the Apple Home App.
|
|
64
|
+
|
|
65
|
+
## Example Configuration
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"bridge": {
|
|
69
|
+
"name": "Homebridge",
|
|
70
|
+
"username": "11:22:33:AA:BB:CC",
|
|
71
|
+
"port": 12345,
|
|
72
|
+
"pin": "001-02-003"
|
|
73
|
+
},
|
|
74
|
+
"description": "This is an example configuration file.",
|
|
75
|
+
"platforms": [
|
|
76
|
+
{
|
|
77
|
+
"platform": "TTLockAccessCode",
|
|
78
|
+
"name": "TTLockAccessCode",
|
|
79
|
+
"clientId": "YourClientID",
|
|
80
|
+
"clientSecret": "YourClientSecret",
|
|
81
|
+
"username": "YourUsername",
|
|
82
|
+
"password": "YourPassword",
|
|
83
|
+
"totalApiCallsPerMonth": 30000,
|
|
84
|
+
"pollingInterval": 300,
|
|
85
|
+
"discoveryPollingInterval": 12,
|
|
86
|
+
"offlineInterval": 7,
|
|
87
|
+
"waitTimeUpdate": 100
|
|
88
|
+
}
|
|
89
|
+
],
|
|
90
|
+
"accessories": []
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Known Limitations
|
|
95
|
+
- Apple HomeKey is not supported by TTLock readers.
|
|
96
|
+
- Gateway-dependent locks will return gateway-offline conditions if the gateway is unavailable.
|
|
97
|
+
- TTLock Cloud API limits and behavior can change without notice.
|
package/config.schema.json
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
"pluginAlias": "TTLockAccessCode",
|
|
3
3
|
"pluginType": "platform",
|
|
4
4
|
"singular": true,
|
|
5
|
+
"strictValidation": true,
|
|
5
6
|
"headerDisplay": "TTLock Access Code Plugin.<p>Follow the [README](https://github.com/ZeliardM/homebridge-ttlock-accesscode/blob/latest/README.md) for configuration instructions and then click \"Save\" to get started.</p>",
|
|
6
7
|
"footerDisplay": "",
|
|
7
8
|
"schema": {
|
|
@@ -50,20 +51,20 @@
|
|
|
50
51
|
"title": "Total API Calls Per Month",
|
|
51
52
|
"type": "integer",
|
|
52
53
|
"description": "Total number of TTLock API calls allowed per month (used to automatically adjust polling).",
|
|
53
|
-
"default":
|
|
54
|
-
"minimum":
|
|
54
|
+
"default": 30000,
|
|
55
|
+
"minimum": 30000
|
|
55
56
|
},
|
|
56
57
|
"pollingInterval": {
|
|
57
58
|
"title": "Polling Interval (seconds)",
|
|
58
59
|
"type": "integer",
|
|
59
60
|
"description": "How often to check device status in the background (seconds)",
|
|
60
|
-
"default":
|
|
61
|
+
"default": 300
|
|
61
62
|
},
|
|
62
63
|
"discoveryPollingInterval": {
|
|
63
64
|
"title": "Discovery Polling Interval (hours)",
|
|
64
65
|
"type": "integer",
|
|
65
66
|
"description": "How often to discover new devices in the background (hours)",
|
|
66
|
-
"default":
|
|
67
|
+
"default": 12
|
|
67
68
|
},
|
|
68
69
|
"offlineInterval": {
|
|
69
70
|
"title": "Offline Interval (days)",
|
package/dist/api/ttlockApi.d.ts
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
import type { Logging } from 'homebridge';
|
|
2
2
|
import { UsageTracker } from './usageTracker.js';
|
|
3
3
|
import { FeatureInfo, Passcode, SysInfo, TTLockDevice } from '../devices/deviceTypes.js';
|
|
4
|
+
export declare enum TTLockApiErrorCategory {
|
|
5
|
+
AuthExpired = "AuthExpired",
|
|
6
|
+
AuthInvalid = "AuthInvalid",
|
|
7
|
+
BudgetExhausted = "BudgetExhausted",
|
|
8
|
+
ClientInvalidRequest = "ClientInvalidRequest",
|
|
9
|
+
InvalidResponse = "InvalidResponse",
|
|
10
|
+
NetworkTransient = "NetworkTransient",
|
|
11
|
+
RateLimited = "RateLimited",
|
|
12
|
+
ServerTransient = "ServerTransient",
|
|
13
|
+
Unknown = "Unknown"
|
|
14
|
+
}
|
|
15
|
+
export declare class TTLockApiError extends Error {
|
|
16
|
+
readonly category: TTLockApiErrorCategory;
|
|
17
|
+
readonly retryable: boolean;
|
|
18
|
+
readonly endpoint?: string | undefined;
|
|
19
|
+
readonly statusCode?: number | undefined;
|
|
20
|
+
readonly errcode?: number | undefined;
|
|
21
|
+
readonly cause?: unknown | undefined;
|
|
22
|
+
wasLogged: boolean;
|
|
23
|
+
constructor(message: string, category: TTLockApiErrorCategory, retryable: boolean, endpoint?: string | undefined, statusCode?: number | undefined, errcode?: number | undefined, cause?: unknown | undefined);
|
|
24
|
+
get authRelated(): boolean;
|
|
25
|
+
}
|
|
4
26
|
export declare class TTLockApi {
|
|
5
27
|
private log;
|
|
6
28
|
private clientId;
|
|
@@ -10,12 +32,26 @@ export declare class TTLockApi {
|
|
|
10
32
|
refreshToken: string | null;
|
|
11
33
|
private tokenMutex;
|
|
12
34
|
private usageTracker;
|
|
35
|
+
private authUsername;
|
|
36
|
+
private authPassword;
|
|
37
|
+
private readonly requestTimeoutMs;
|
|
38
|
+
private readonly maxRetries;
|
|
13
39
|
constructor(log: Logging, clientId: string, clientSecret: string, usageTracker?: UsageTracker);
|
|
14
40
|
private encryptPassword;
|
|
15
41
|
authenticate(username: string, password: string): Promise<void>;
|
|
16
|
-
private
|
|
42
|
+
private authenticateWithPassword;
|
|
43
|
+
private refreshTokenIfNeededLocked;
|
|
44
|
+
private recoverAuthentication;
|
|
17
45
|
private makeAuthenticatedRequest;
|
|
18
|
-
private
|
|
46
|
+
private logApiError;
|
|
47
|
+
private isGatewayOfflineError;
|
|
48
|
+
private normalizeApiResponseError;
|
|
49
|
+
private normalizeError;
|
|
50
|
+
private looksAuthError;
|
|
51
|
+
private looksRateLimitError;
|
|
52
|
+
private getRetryDelayMs;
|
|
53
|
+
private sleep;
|
|
54
|
+
private isObject;
|
|
19
55
|
getDevices(): Promise<TTLockDevice[]>;
|
|
20
56
|
getDeviceDetails(lockId: string): Promise<Partial<SysInfo>>;
|
|
21
57
|
getSysInfo(lockId: string): Promise<Partial<SysInfo>>;
|
package/dist/api/ttlockApi.js
CHANGED
|
@@ -1,10 +1,38 @@
|
|
|
1
1
|
import axios from 'axios';
|
|
2
2
|
import crypto from 'crypto';
|
|
3
3
|
import { SimpleMutex } from '../utils.js';
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
export var TTLockApiErrorCategory;
|
|
5
|
+
(function (TTLockApiErrorCategory) {
|
|
6
|
+
TTLockApiErrorCategory["AuthExpired"] = "AuthExpired";
|
|
7
|
+
TTLockApiErrorCategory["AuthInvalid"] = "AuthInvalid";
|
|
8
|
+
TTLockApiErrorCategory["BudgetExhausted"] = "BudgetExhausted";
|
|
9
|
+
TTLockApiErrorCategory["ClientInvalidRequest"] = "ClientInvalidRequest";
|
|
10
|
+
TTLockApiErrorCategory["InvalidResponse"] = "InvalidResponse";
|
|
11
|
+
TTLockApiErrorCategory["NetworkTransient"] = "NetworkTransient";
|
|
12
|
+
TTLockApiErrorCategory["RateLimited"] = "RateLimited";
|
|
13
|
+
TTLockApiErrorCategory["ServerTransient"] = "ServerTransient";
|
|
14
|
+
TTLockApiErrorCategory["Unknown"] = "Unknown";
|
|
15
|
+
})(TTLockApiErrorCategory || (TTLockApiErrorCategory = {}));
|
|
16
|
+
export class TTLockApiError extends Error {
|
|
17
|
+
category;
|
|
18
|
+
retryable;
|
|
19
|
+
endpoint;
|
|
20
|
+
statusCode;
|
|
21
|
+
errcode;
|
|
22
|
+
cause;
|
|
23
|
+
wasLogged = false;
|
|
24
|
+
constructor(message, category, retryable, endpoint, statusCode, errcode, cause) {
|
|
6
25
|
super(message);
|
|
7
|
-
this.
|
|
26
|
+
this.category = category;
|
|
27
|
+
this.retryable = retryable;
|
|
28
|
+
this.endpoint = endpoint;
|
|
29
|
+
this.statusCode = statusCode;
|
|
30
|
+
this.errcode = errcode;
|
|
31
|
+
this.cause = cause;
|
|
32
|
+
this.name = 'TTLockApiError';
|
|
33
|
+
}
|
|
34
|
+
get authRelated() {
|
|
35
|
+
return this.category === TTLockApiErrorCategory.AuthExpired || this.category === TTLockApiErrorCategory.AuthInvalid;
|
|
8
36
|
}
|
|
9
37
|
}
|
|
10
38
|
export class TTLockApi {
|
|
@@ -16,6 +44,10 @@ export class TTLockApi {
|
|
|
16
44
|
refreshToken = null;
|
|
17
45
|
tokenMutex = new SimpleMutex();
|
|
18
46
|
usageTracker;
|
|
47
|
+
authUsername = null;
|
|
48
|
+
authPassword = null;
|
|
49
|
+
requestTimeoutMs = 15000;
|
|
50
|
+
maxRetries = 3;
|
|
19
51
|
constructor(log, clientId, clientSecret, usageTracker) {
|
|
20
52
|
this.log = log;
|
|
21
53
|
this.clientId = clientId;
|
|
@@ -25,6 +57,7 @@ export class TTLockApi {
|
|
|
25
57
|
headers: {
|
|
26
58
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
27
59
|
},
|
|
60
|
+
timeout: this.requestTimeoutMs,
|
|
28
61
|
});
|
|
29
62
|
this.log.debug('TTLockApi initialized');
|
|
30
63
|
this.usageTracker = usageTracker;
|
|
@@ -33,51 +66,52 @@ export class TTLockApi {
|
|
|
33
66
|
return crypto.createHash('md5').update(password).digest('hex');
|
|
34
67
|
}
|
|
35
68
|
async authenticate(username, password) {
|
|
69
|
+
this.authUsername = username;
|
|
70
|
+
this.authPassword = password;
|
|
71
|
+
await this.authenticateWithPassword(username, password);
|
|
72
|
+
}
|
|
73
|
+
async authenticateWithPassword(username, password) {
|
|
36
74
|
this.log.debug('Authenticating with TTLock API...');
|
|
37
75
|
try {
|
|
38
76
|
const encryptedPassword = this.encryptPassword(password);
|
|
39
77
|
if (this.usageTracker) {
|
|
40
78
|
const ok = await this.usageTracker.tryReserve(1, 'authenticate');
|
|
41
79
|
if (!ok) {
|
|
42
|
-
throw new
|
|
80
|
+
throw new TTLockApiError('API usage budget exhausted (authenticate)', TTLockApiErrorCategory.BudgetExhausted, false, 'oauth2/token');
|
|
43
81
|
}
|
|
44
82
|
}
|
|
45
|
-
const response = await
|
|
46
|
-
this.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}).toString()),
|
|
53
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error('TTLock API authentication timed out')), 15000)),
|
|
54
|
-
]);
|
|
83
|
+
const response = await this.apiClient.post('oauth2/token', new URLSearchParams({
|
|
84
|
+
client_id: this.clientId,
|
|
85
|
+
client_secret: this.clientSecret,
|
|
86
|
+
grant_type: 'password',
|
|
87
|
+
username,
|
|
88
|
+
password: encryptedPassword,
|
|
89
|
+
}).toString());
|
|
55
90
|
if (response.data.access_token && response.data.refresh_token) {
|
|
56
91
|
this.accessToken = response.data.access_token;
|
|
57
92
|
this.refreshToken = response.data.refresh_token;
|
|
58
93
|
this.log.info('Authenticated with TTLock API');
|
|
59
94
|
}
|
|
60
95
|
else {
|
|
61
|
-
|
|
62
|
-
throw new Error('Authentication failed: No tokens received');
|
|
96
|
+
throw new TTLockApiError(`Authentication failed: invalid token response (${JSON.stringify(response.data)})`, TTLockApiErrorCategory.AuthInvalid, false, 'oauth2/token', undefined, response.data.errcode);
|
|
63
97
|
}
|
|
64
98
|
}
|
|
65
99
|
catch (error) {
|
|
66
|
-
this.
|
|
67
|
-
|
|
100
|
+
const normalized = this.normalizeError(error, 'oauth2/token');
|
|
101
|
+
this.logApiError('Failed to authenticate with TTLock API', normalized);
|
|
102
|
+
throw normalized;
|
|
68
103
|
}
|
|
69
104
|
}
|
|
70
|
-
async
|
|
105
|
+
async refreshTokenIfNeededLocked() {
|
|
71
106
|
if (!this.refreshToken) {
|
|
72
|
-
throw new
|
|
107
|
+
throw new TTLockApiError('No refresh token available. Full re-authentication required.', TTLockApiErrorCategory.AuthInvalid, false, 'oauth2/token');
|
|
73
108
|
}
|
|
74
|
-
const release = await this.tokenMutex.acquire();
|
|
75
109
|
try {
|
|
76
110
|
this.log.debug('Refreshing access token...');
|
|
77
111
|
if (this.usageTracker) {
|
|
78
112
|
const ok = await this.usageTracker.tryReserve(1, 'refreshToken');
|
|
79
113
|
if (!ok) {
|
|
80
|
-
throw new
|
|
114
|
+
throw new TTLockApiError('API usage budget exhausted (refreshToken)', TTLockApiErrorCategory.BudgetExhausted, false, 'oauth2/token');
|
|
81
115
|
}
|
|
82
116
|
}
|
|
83
117
|
const response = await this.apiClient.post('oauth2/token', new URLSearchParams({
|
|
@@ -92,32 +126,68 @@ export class TTLockApi {
|
|
|
92
126
|
this.log.debug('Access token refreshed');
|
|
93
127
|
}
|
|
94
128
|
else {
|
|
95
|
-
throw new
|
|
129
|
+
throw new TTLockApiError(`Failed to refresh token: invalid response (${JSON.stringify(response.data)})`, TTLockApiErrorCategory.AuthInvalid, false, 'oauth2/token', undefined, response.data.errcode);
|
|
96
130
|
}
|
|
97
131
|
}
|
|
98
132
|
catch (error) {
|
|
99
|
-
this.
|
|
100
|
-
|
|
133
|
+
throw this.normalizeError(error, 'oauth2/token');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async recoverAuthentication(failedToken, endpoint) {
|
|
137
|
+
const release = await this.tokenMutex.acquire();
|
|
138
|
+
try {
|
|
139
|
+
if (this.accessToken && this.accessToken !== failedToken) {
|
|
140
|
+
this.log.debug(`Token already refreshed by a concurrent request for endpoint ${endpoint}`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
await this.refreshTokenIfNeededLocked();
|
|
145
|
+
this.log.debug(`Recovered authentication via refresh token for endpoint ${endpoint}`);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
catch (refreshError) {
|
|
149
|
+
const normalizedRefreshError = this.normalizeError(refreshError, endpoint);
|
|
150
|
+
this.log.warn(`Refresh token flow failed for endpoint ${endpoint}: ${normalizedRefreshError.message}`);
|
|
151
|
+
}
|
|
152
|
+
if (!this.authUsername || !this.authPassword) {
|
|
153
|
+
throw new TTLockApiError('No stored credentials available for full re-authentication', TTLockApiErrorCategory.AuthInvalid, false, endpoint);
|
|
154
|
+
}
|
|
155
|
+
await this.authenticateWithPassword(this.authUsername, this.authPassword);
|
|
156
|
+
this.log.info(`Recovered authentication via full credential re-login for endpoint ${endpoint}`);
|
|
101
157
|
}
|
|
102
158
|
finally {
|
|
103
159
|
release();
|
|
104
160
|
}
|
|
105
161
|
}
|
|
106
162
|
async makeAuthenticatedRequest(endpoint, method = 'GET', data) {
|
|
107
|
-
const
|
|
163
|
+
const fullEndpoint = `v3/${endpoint}`;
|
|
164
|
+
let authRecoveryAttempted = false;
|
|
165
|
+
let lastError;
|
|
166
|
+
if (!this.accessToken && this.authUsername && this.authPassword) {
|
|
167
|
+
await this.authenticateWithPassword(this.authUsername, this.authPassword);
|
|
168
|
+
}
|
|
108
169
|
if (!this.accessToken) {
|
|
109
|
-
throw new
|
|
170
|
+
throw new TTLockApiError('Not authenticated and credentials are unavailable', TTLockApiErrorCategory.AuthInvalid, false, endpoint);
|
|
110
171
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
172
|
+
if (this.usageTracker) {
|
|
173
|
+
const consumedReserved = await this.usageTracker.consumePendingReservation(1);
|
|
174
|
+
if (!consumedReserved) {
|
|
175
|
+
const ok = await this.usageTracker.tryReserve(1, `request:${endpoint}`);
|
|
176
|
+
if (!ok) {
|
|
177
|
+
throw new TTLockApiError(`API usage budget exhausted (${endpoint})`, TTLockApiErrorCategory.BudgetExhausted, false, endpoint);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
|
|
182
|
+
let attemptedToken = this.accessToken;
|
|
120
183
|
try {
|
|
184
|
+
attemptedToken = this.accessToken;
|
|
185
|
+
const requestData = {
|
|
186
|
+
...data,
|
|
187
|
+
clientId: this.clientId,
|
|
188
|
+
accessToken: this.accessToken,
|
|
189
|
+
date: Date.now(),
|
|
190
|
+
};
|
|
121
191
|
const response = await this.apiClient.request({
|
|
122
192
|
url: fullEndpoint,
|
|
123
193
|
method,
|
|
@@ -132,82 +202,133 @@ export class TTLockApi {
|
|
|
132
202
|
}, {})).toString()
|
|
133
203
|
: undefined,
|
|
134
204
|
});
|
|
135
|
-
if ('errcode' in response.data && response.data.errcode !== 0) {
|
|
136
|
-
|
|
205
|
+
if (this.isObject(response.data) && 'errcode' in response.data && response.data.errcode !== 0) {
|
|
206
|
+
const payload = response.data;
|
|
207
|
+
throw this.normalizeApiResponseError(payload, endpoint);
|
|
137
208
|
}
|
|
138
209
|
return response.data;
|
|
139
210
|
}
|
|
140
211
|
catch (error) {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
break;
|
|
212
|
+
const normalized = this.normalizeError(error, endpoint);
|
|
213
|
+
lastError = normalized;
|
|
214
|
+
if (normalized.authRelated && !authRecoveryAttempted) {
|
|
215
|
+
authRecoveryAttempted = true;
|
|
216
|
+
this.log.warn(`Auth failure for ${endpoint}; attempting internal re-authentication`);
|
|
217
|
+
await this.recoverAuthentication(attemptedToken, endpoint);
|
|
218
|
+
continue;
|
|
152
219
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
-
catch (error) {
|
|
162
|
-
lastError = error;
|
|
163
|
-
break;
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
const rf = new RequestFailed(`Request failed: status=${error.response.status}, body=${JSON.stringify(error.response.data)}`);
|
|
167
|
-
this.log.error(rf.message);
|
|
168
|
-
if (error.response.status >= 500 && attempt < maxRetries) {
|
|
169
|
-
const delay = Math.pow(2, attempt) * 1000;
|
|
170
|
-
this.log.debug(`Server error, retrying in ${delay / 1000} seconds...`);
|
|
171
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
172
|
-
lastError = rf;
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
lastError = rf;
|
|
176
|
-
break;
|
|
177
|
-
}
|
|
178
|
-
this.log.debug(`Network error on attempt ${attempt + 1}: ${error.message}`);
|
|
179
|
-
if (attempt < maxRetries) {
|
|
180
|
-
const delay = Math.pow(2, attempt) * 1000;
|
|
181
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
182
|
-
continue;
|
|
183
|
-
}
|
|
184
|
-
break;
|
|
220
|
+
const isLastAttempt = attempt >= this.maxRetries;
|
|
221
|
+
if (!isLastAttempt && normalized.retryable) {
|
|
222
|
+
const delay = this.getRetryDelayMs(attempt);
|
|
223
|
+
this.log.warn(`Retryable TTLock API error on ${endpoint}; category=${normalized.category}; ` +
|
|
224
|
+
`attempt=${attempt + 1}/${this.maxRetries + 1}; retrying in ${delay}ms`);
|
|
225
|
+
await this.sleep(delay);
|
|
226
|
+
continue;
|
|
185
227
|
}
|
|
186
|
-
this.
|
|
187
|
-
|
|
228
|
+
if (!this.isGatewayOfflineError(normalized)) {
|
|
229
|
+
this.logApiError(`Request failed for ${endpoint}`, normalized);
|
|
230
|
+
}
|
|
231
|
+
throw normalized;
|
|
188
232
|
}
|
|
189
233
|
}
|
|
190
|
-
if (lastError
|
|
234
|
+
if (lastError) {
|
|
191
235
|
throw lastError;
|
|
192
236
|
}
|
|
193
|
-
throw new
|
|
237
|
+
throw new TTLockApiError('Max retries reached', TTLockApiErrorCategory.Unknown, false, endpoint);
|
|
238
|
+
}
|
|
239
|
+
logApiError(message, error) {
|
|
240
|
+
if (error.wasLogged) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
error.wasLogged = true;
|
|
244
|
+
if (this.isGatewayOfflineError(error)) {
|
|
245
|
+
this.log.warn(`${message}: gateway offline (endpoint=${error.endpoint ?? 'unknown'} errcode=${error.errcode ?? 'n/a'}). ` +
|
|
246
|
+
'Will retry on next poll/discovery cycle.');
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
this.log.error(`${message}: category=${error.category} retryable=${error.retryable} endpoint=${error.endpoint ?? 'unknown'} ` +
|
|
250
|
+
`status=${error.statusCode ?? 'n/a'} errcode=${error.errcode ?? 'n/a'} message=${error.message}`);
|
|
251
|
+
}
|
|
252
|
+
isGatewayOfflineError(error) {
|
|
253
|
+
return error.errcode === -3002 || /gateway is offline/i.test(error.message);
|
|
254
|
+
}
|
|
255
|
+
normalizeApiResponseError(payload, endpoint) {
|
|
256
|
+
const errcode = payload.errcode;
|
|
257
|
+
const errmsg = payload.errmsg ?? 'Unknown API error';
|
|
258
|
+
if (this.looksAuthError(undefined, errcode, errmsg)) {
|
|
259
|
+
return new TTLockApiError(`TTLock authentication error: ${errmsg}`, TTLockApiErrorCategory.AuthExpired, false, endpoint, undefined, errcode);
|
|
260
|
+
}
|
|
261
|
+
if (this.looksRateLimitError(undefined, errcode, errmsg)) {
|
|
262
|
+
return new TTLockApiError(`TTLock rate limit error: ${errmsg}`, TTLockApiErrorCategory.RateLimited, true, endpoint, undefined, errcode);
|
|
263
|
+
}
|
|
264
|
+
return new TTLockApiError(`TTLock API returned errcode=${errcode ?? 'n/a'} errmsg=${errmsg}`, TTLockApiErrorCategory.ClientInvalidRequest, false, endpoint, undefined, errcode);
|
|
194
265
|
}
|
|
195
|
-
|
|
266
|
+
normalizeError(error, endpoint) {
|
|
267
|
+
if (error instanceof TTLockApiError) {
|
|
268
|
+
return error;
|
|
269
|
+
}
|
|
196
270
|
if (axios.isAxiosError(error)) {
|
|
197
|
-
|
|
271
|
+
const status = error.response?.status;
|
|
272
|
+
const responseData = error.response?.data;
|
|
273
|
+
const errcode = this.isObject(responseData) ? responseData.errcode : undefined;
|
|
274
|
+
const errmsg = this.isObject(responseData) ? responseData.errmsg : undefined;
|
|
275
|
+
if (this.looksAuthError(status, errcode, errmsg)) {
|
|
276
|
+
return new TTLockApiError(`Authentication failed for ${endpoint ?? 'request'}: ${errmsg ?? error.message}`, TTLockApiErrorCategory.AuthExpired, false, endpoint, status, errcode, error);
|
|
277
|
+
}
|
|
278
|
+
if (this.looksRateLimitError(status, errcode, errmsg)) {
|
|
279
|
+
return new TTLockApiError(`Rate limited on ${endpoint ?? 'request'}: ${errmsg ?? error.message}`, TTLockApiErrorCategory.RateLimited, true, endpoint, status, errcode, error);
|
|
280
|
+
}
|
|
281
|
+
if (!error.response || error.code === 'ECONNABORTED') {
|
|
282
|
+
return new TTLockApiError(`Network error on ${endpoint ?? 'request'}: ${error.message}`, TTLockApiErrorCategory.NetworkTransient, true, endpoint, status, errcode, error);
|
|
283
|
+
}
|
|
284
|
+
if (status !== undefined && status >= 500) {
|
|
285
|
+
return new TTLockApiError(`Server error on ${endpoint ?? 'request'}: status=${status}`, TTLockApiErrorCategory.ServerTransient, true, endpoint, status, errcode, error);
|
|
286
|
+
}
|
|
287
|
+
if (status !== undefined && status >= 400) {
|
|
288
|
+
return new TTLockApiError(`Client error on ${endpoint ?? 'request'}: status=${status}`, TTLockApiErrorCategory.ClientInvalidRequest, false, endpoint, status, errcode, error);
|
|
289
|
+
}
|
|
290
|
+
return new TTLockApiError(`Unknown axios error on ${endpoint ?? 'request'}: ${error.message}`, TTLockApiErrorCategory.Unknown, false, endpoint, status, errcode, error);
|
|
291
|
+
}
|
|
292
|
+
if (error instanceof Error) {
|
|
293
|
+
const isTimeout = /timed out|timeout/i.test(error.message);
|
|
294
|
+
return new TTLockApiError(error.message, isTimeout ? TTLockApiErrorCategory.NetworkTransient : TTLockApiErrorCategory.Unknown, isTimeout, endpoint, undefined, undefined, error);
|
|
198
295
|
}
|
|
199
|
-
|
|
200
|
-
|
|
296
|
+
return new TTLockApiError(`Unknown error: ${JSON.stringify(error)}`, TTLockApiErrorCategory.Unknown, false, endpoint, undefined, undefined, error);
|
|
297
|
+
}
|
|
298
|
+
looksAuthError(status, errcode, message) {
|
|
299
|
+
if (status === 401 || status === 403) {
|
|
300
|
+
return true;
|
|
201
301
|
}
|
|
202
|
-
|
|
203
|
-
|
|
302
|
+
if (typeof errcode === 'number' && [10002, 10003, 10004, 10005, 10006].includes(errcode)) {
|
|
303
|
+
return true;
|
|
204
304
|
}
|
|
305
|
+
return /token|auth|expired|invalid_grant|credential|login/i.test(message ?? '');
|
|
306
|
+
}
|
|
307
|
+
looksRateLimitError(status, errcode, message) {
|
|
308
|
+
if (status === 429) {
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
if (typeof errcode === 'number' && [10018, 10019, 10020].includes(errcode)) {
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
return /too many|rate.?limit|frequency|limit/i.test(message ?? '');
|
|
315
|
+
}
|
|
316
|
+
getRetryDelayMs(attempt) {
|
|
317
|
+
const baseDelay = Math.min(8000, Math.pow(2, attempt) * 1000);
|
|
318
|
+
const jitter = Math.floor(Math.random() * 250);
|
|
319
|
+
return baseDelay + jitter;
|
|
320
|
+
}
|
|
321
|
+
sleep(ms) {
|
|
322
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
323
|
+
}
|
|
324
|
+
isObject(value) {
|
|
325
|
+
return typeof value === 'object' && value !== null;
|
|
205
326
|
}
|
|
206
327
|
async getDevices() {
|
|
207
328
|
const response = await this.makeAuthenticatedRequest('lock/list', 'GET', { pageNo: 1, pageSize: 1000 });
|
|
208
329
|
if (!response.list || !Array.isArray(response.list)) {
|
|
209
330
|
this.log.error('Invalid response format: expected list of locks');
|
|
210
|
-
throw new
|
|
331
|
+
throw new TTLockApiError('Invalid response format: expected list of locks', TTLockApiErrorCategory.InvalidResponse, false, 'lock/list');
|
|
211
332
|
}
|
|
212
333
|
const lockList = [];
|
|
213
334
|
for (const lock of response.list) {
|
|
@@ -227,7 +348,8 @@ export class TTLockApi {
|
|
|
227
348
|
lockList.push(ttlockDevice);
|
|
228
349
|
}
|
|
229
350
|
catch (error) {
|
|
230
|
-
this.
|
|
351
|
+
const normalized = this.normalizeError(error, 'lock/detail|lock/queryOpenState');
|
|
352
|
+
this.logApiError(`Failed to fetch details for lock ${lock.lockId}`, normalized);
|
|
231
353
|
continue;
|
|
232
354
|
}
|
|
233
355
|
}
|
|
@@ -237,6 +359,9 @@ export class TTLockApi {
|
|
|
237
359
|
const sysInfo = {};
|
|
238
360
|
const detailResponse = await this.makeAuthenticatedRequest('lock/detail', 'GET', { lockId });
|
|
239
361
|
const stateResponse = await this.makeAuthenticatedRequest('lock/queryOpenState', 'GET', { lockId });
|
|
362
|
+
if (!detailResponse.firmwareRevision || !detailResponse.hardwareRevision || !detailResponse.modelNum) {
|
|
363
|
+
throw new TTLockApiError(`Invalid lock/detail response for lockId=${lockId}`, TTLockApiErrorCategory.InvalidResponse, false, 'lock/detail');
|
|
364
|
+
}
|
|
240
365
|
sysInfo.fw_ver = detailResponse.firmwareRevision.split('.').slice(0, 3).join('.');
|
|
241
366
|
sysInfo.hw_ver = detailResponse.hardwareRevision;
|
|
242
367
|
sysInfo.model = detailResponse.modelNum;
|
|
@@ -266,7 +391,8 @@ export class TTLockApi {
|
|
|
266
391
|
return featureInfo;
|
|
267
392
|
}
|
|
268
393
|
catch (error) {
|
|
269
|
-
this.
|
|
394
|
+
const normalized = this.normalizeError(error, 'feature/parse');
|
|
395
|
+
this.logApiError('Failed to parse featureValue', normalized);
|
|
270
396
|
featureInfo.passcode = false;
|
|
271
397
|
return featureInfo;
|
|
272
398
|
}
|
|
@@ -286,12 +412,12 @@ export class TTLockApi {
|
|
|
286
412
|
const response = await this.makeAuthenticatedRequest('lock/listKeyboardPwd', 'GET', { lockId, pageNo: 1, pageSize: 1000, orderBy: 0 });
|
|
287
413
|
if (!response.list || !Array.isArray(response.list)) {
|
|
288
414
|
this.log.error('Invalid response format: expected list of passcodes');
|
|
289
|
-
throw new
|
|
415
|
+
throw new TTLockApiError('Invalid response format: expected list of passcodes', TTLockApiErrorCategory.InvalidResponse, false, 'lock/listKeyboardPwd');
|
|
290
416
|
}
|
|
291
417
|
this.log.debug(`Found ${response.list.length} passcodes for lock: ${lockId}`);
|
|
292
418
|
return response.list.map((item, index) => ({
|
|
293
419
|
passcode_id: item.keyboardPwdId.toString(),
|
|
294
|
-
index:
|
|
420
|
+
index: index.toString(),
|
|
295
421
|
lock_id: item.lockId.toString(),
|
|
296
422
|
passcode: item.keyboardPwd,
|
|
297
423
|
}));
|