homebridge-ttlock-accesscode 2.0.0-beta.7 → 2.0.1-beta.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 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
- <div align="center">
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
- >## PLEASE READ!!!
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
- </div>
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
- This is a [Homebridge](https://github.com/homebridge/homebridge) plug-in based for integrating TTLock smart locks with the TTLock Cloud API.
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
- ## Requirements
38
-
39
- 1. TTLock Smart Lock
40
- 2. A Gateway (If your lock does not have built-in Wi-Fi, I purchased a TTLock G2 Gateway off Amazon.)
41
- 3. Remote Unlock must be enabled through the TTLock App locally within bluetooth or Wi-Fi rnage of your lock.
42
- 4. You must create a TTLock Open API Account. This is not the username and password that will be used in the setting in the plug-in, this is for creating an OAUTH2.0 App with the TTLock Cloud API for the plug-in to get access to your TTLock Devices.
43
-
44
- ## Setup
45
-
46
- 1. Create an account in the [TTLock Cloud API](https://euopen.ttlock.com/register).
47
- 2. Create your OAUTH2.0 App, give it a name, icon, select the 'App' option, and a short description.</br>
48
- *This may take a few days to be approved by their Development Team.*
49
- 3. Once approved, you will need the *client_id* and *client_secret* from the app.
50
- 4. Setup the TTLock Mobile App, create an account in the Mobile App, setup and install your lock(s).</br>
51
- *If your Lock is not Wi-Fi Capable, make sure you setup your Gateway as well and have it close enough to your Lock for the Gateway to pick up the Lock in the Mobile App.*
52
- 5. Once you have your Mobile App Username and Password, and the client_id and client_secret, you can setup the plug-in in Homebridge.
53
-
54
- ## IMPORTANT
55
- I have only tested this with a G2 Gateway.
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.
@@ -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": 100000,
54
- "minimum": 100000
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": 90
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": 6
67
+ "default": 12
67
68
  },
68
69
  "offlineInterval": {
69
70
  "title": "Offline Interval (days)",
@@ -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 refreshTokenIfNeeded;
42
+ private authenticateWithPassword;
43
+ private refreshTokenIfNeededLocked;
44
+ private recoverAuthentication;
17
45
  private makeAuthenticatedRequest;
18
- private handleError;
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>>;
@@ -1,10 +1,38 @@
1
1
  import axios from 'axios';
2
2
  import crypto from 'crypto';
3
3
  import { SimpleMutex } from '../utils.js';
4
- class RequestFailed extends Error {
5
- constructor(message) {
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.name = 'RequestFailed';
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 Error('API usage budget exhausted (authenticate)');
80
+ throw new TTLockApiError('API usage budget exhausted (authenticate)', TTLockApiErrorCategory.BudgetExhausted, false, 'oauth2/token');
43
81
  }
44
82
  }
45
- const response = await Promise.race([
46
- this.apiClient.post('oauth2/token', new URLSearchParams({
47
- client_id: this.clientId,
48
- client_secret: this.clientSecret,
49
- grant_type: 'password',
50
- username,
51
- password: encryptedPassword,
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
- this.log.error('Authentication response did not contain tokens:', response.data);
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.handleError('Failed to authenticate with TTLock API', error);
67
- throw error;
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 refreshTokenIfNeeded() {
105
+ async refreshTokenIfNeededLocked() {
71
106
  if (!this.refreshToken) {
72
- throw new Error('No refresh token available. Please call authenticate() first.');
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 Error('API usage budget exhausted (refreshToken)');
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 Error('Failed to refresh token: Invalid response');
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.handleError('Failed to refresh access token', error);
100
- throw error;
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 maxRetries = 3;
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 Error('Not authenticated. Please call authenticate() first.');
170
+ throw new TTLockApiError('Not authenticated and credentials are unavailable', TTLockApiErrorCategory.AuthInvalid, false, endpoint);
110
171
  }
111
- const requestData = {
112
- ...data,
113
- clientId: this.clientId,
114
- accessToken: this.accessToken,
115
- date: Date.now(),
116
- };
117
- const fullEndpoint = `v3/${endpoint}`;
118
- let lastError = new Error('Unknown request error');
119
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
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
- throw new RequestFailed(`API returned error: ${response.data.errmsg || 'Unknown error'}`);
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
- lastError = error;
142
- if (error instanceof RequestFailed) {
143
- const errorMsg = error.message;
144
- this.log.debug(`Attempt ${attempt + 1} failed: ${errorMsg}`);
145
- if (attempt < maxRetries) {
146
- const delay = Math.pow(2, attempt) * 1000;
147
- this.log.debug(`Retrying in ${delay / 1000} seconds...`);
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
- if (axios.isAxiosError(error)) {
154
- if (error.response) {
155
- if (error.response.status === 401) {
156
- this.log.debug('Access token expired, refreshing token...');
157
- try {
158
- await this.refreshTokenIfNeeded();
159
- continue;
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.handleError('Request failed', error);
187
- break;
228
+ if (!this.isGatewayOfflineError(normalized)) {
229
+ this.logApiError(`Request failed for ${endpoint}`, normalized);
230
+ }
231
+ throw normalized;
188
232
  }
189
233
  }
190
- if (lastError instanceof Error) {
234
+ if (lastError) {
191
235
  throw lastError;
192
236
  }
193
- throw new Error('Max retries reached');
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
- handleError(message, error) {
266
+ normalizeError(error, endpoint) {
267
+ if (error instanceof TTLockApiError) {
268
+ return error;
269
+ }
196
270
  if (axios.isAxiosError(error)) {
197
- this.log.error(`${message}: ${error.response?.data || error.message}`);
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
- else if (error instanceof Error) {
200
- this.log.error(`${message}: ${error.message}`);
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
- else {
203
- this.log.error(`${message}: ${JSON.stringify(error)}`);
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 Error('Invalid response format: expected list of locks');
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.handleError(`Failed to fetch details for lock ${lock.lockId}`, error);
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.handleError('Failed to parse featureValue', error);
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 Error('Invalid response format: expected list of passcodes');
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: (index).toString(),
420
+ index: index.toString(),
295
421
  lock_id: item.lockId.toString(),
296
422
  passcode: item.keyboardPwd,
297
423
  }));