homebridge-omlet 0.9.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/LICENSE +201 -0
- package/README.md +177 -0
- package/config.schema.json +109 -0
- package/homebridge-ui/public/index.html +918 -0
- package/homebridge-ui/server.js +309 -0
- package/index.js +1310 -0
- package/logo.png +0 -0
- package/package.json +36 -0
package/index.js
ADDED
|
@@ -0,0 +1,1310 @@
|
|
|
1
|
+
const https = require('https');
|
|
2
|
+
const fs = require('fs');
|
|
3
|
+
|
|
4
|
+
let hap;
|
|
5
|
+
|
|
6
|
+
module.exports = (api) => {
|
|
7
|
+
hap = api.hap;
|
|
8
|
+
api.registerPlatform('homebridge-omlet', 'OmletCoop', OmletCoopPlatform);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
class OmletCoopPlatform {
|
|
12
|
+
constructor(log, config, api) {
|
|
13
|
+
this.log = log;
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.api = api;
|
|
16
|
+
|
|
17
|
+
// Configuration with validation
|
|
18
|
+
this.email = this.validateEmail(config.email);
|
|
19
|
+
this.password = config.password;
|
|
20
|
+
this.countryCode = this.validateCountryCode(config.countryCode);
|
|
21
|
+
this.bearerToken = this.validateToken(config.bearerToken, 'bearerToken');
|
|
22
|
+
this.deviceId = this.validateDeviceId(config.deviceId, 'deviceId');
|
|
23
|
+
this.baseUrl = this.validateHostname(config.apiServer) || 'x107.omlet.co.uk';
|
|
24
|
+
this.pollInterval = this.validatePollInterval(config.pollInterval);
|
|
25
|
+
this.enableLight = config.enableLight !== false; // Default true for backwards compatibility
|
|
26
|
+
this.enableBattery = config.enableBattery === true; // Default false (not visible in Home app)
|
|
27
|
+
this.debug = config.debug || false;
|
|
28
|
+
|
|
29
|
+
// Token management
|
|
30
|
+
this.currentToken = null;
|
|
31
|
+
this.storage = this.api.user.storagePath() + '/omlet-coop-tokens.json';
|
|
32
|
+
this.authFailedPermanently = false; // Set to true after 3 re-login attempts fail
|
|
33
|
+
this.reloginAttempts = 0; // Track number of re-login attempts
|
|
34
|
+
this.maxReloginAttempts = 3;
|
|
35
|
+
|
|
36
|
+
this.accessories = [];
|
|
37
|
+
|
|
38
|
+
this.log.info('Omlet Coop platform loaded');
|
|
39
|
+
if (this.debug) {
|
|
40
|
+
this.log.info('Debug mode enabled');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Validate config
|
|
44
|
+
const hasEmailPassword = this.email && this.password;
|
|
45
|
+
const hasManualToken = this.bearerToken && this.deviceId;
|
|
46
|
+
|
|
47
|
+
if (!hasEmailPassword && !hasManualToken) {
|
|
48
|
+
this.log.error('Enter email address & password to configure plugin');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
this.api.on('didFinishLaunching', async () => {
|
|
53
|
+
// Load stored credentials (token/deviceId) if available
|
|
54
|
+
await this.loadStoredCredentials();
|
|
55
|
+
|
|
56
|
+
await this.initialize();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// === VALIDATION METHODS ===
|
|
61
|
+
|
|
62
|
+
validatePollInterval(value) {
|
|
63
|
+
// Convert to integer, handling strings and other types
|
|
64
|
+
const interval = parseInt(value);
|
|
65
|
+
|
|
66
|
+
// If NaN or invalid, use default
|
|
67
|
+
if (isNaN(interval)) {
|
|
68
|
+
if (value !== undefined && value !== null) {
|
|
69
|
+
this.log.warn(`Invalid pollInterval "${value}", using default 30 seconds`);
|
|
70
|
+
}
|
|
71
|
+
return 30 * 1000;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Enforce min 30 seconds
|
|
75
|
+
if (interval < 30) {
|
|
76
|
+
this.log.warn(`pollInterval ${interval} is too low, enforcing minimum of 30 seconds`);
|
|
77
|
+
return 30 * 1000;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Enforce max 300 seconds (5 minutes)
|
|
81
|
+
if (interval > 300) {
|
|
82
|
+
this.log.warn(`pollInterval ${interval} is too high, enforcing maximum of 300 seconds`);
|
|
83
|
+
return 300 * 1000;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return interval * 1000;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
validateEmail(email) {
|
|
90
|
+
if (!email) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Basic email validation: has @ and . in the right places
|
|
95
|
+
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
96
|
+
|
|
97
|
+
if (!emailRegex.test(email)) {
|
|
98
|
+
this.log.error(`Invalid email format: "${email}"`);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return email;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
validateCountryCode(code) {
|
|
106
|
+
if (!code) {
|
|
107
|
+
return 'US'; // Default
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Must be exactly 2 uppercase letters
|
|
111
|
+
const codeRegex = /^[A-Z]{2}$/;
|
|
112
|
+
|
|
113
|
+
if (!codeRegex.test(code)) {
|
|
114
|
+
this.log.warn(`Invalid country code "${code}", using default "US"`);
|
|
115
|
+
return 'US';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return code;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
validateToken(token, fieldName = 'token') {
|
|
122
|
+
if (!token) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Must be alphanumeric, max 64 characters
|
|
127
|
+
const tokenRegex = /^[a-zA-Z0-9]{1,64}$/;
|
|
128
|
+
|
|
129
|
+
if (!tokenRegex.test(token)) {
|
|
130
|
+
this.log.error(`Invalid ${fieldName}: must be alphanumeric and less than 64 characters`);
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return token;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
validateDeviceId(deviceId, fieldName = 'deviceId') {
|
|
138
|
+
if (!deviceId) {
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Must be alphanumeric, max 32 characters
|
|
143
|
+
const deviceIdRegex = /^[a-zA-Z0-9]{1,32}$/;
|
|
144
|
+
|
|
145
|
+
if (!deviceIdRegex.test(deviceId)) {
|
|
146
|
+
this.log.error(`Invalid ${fieldName}: must be alphanumeric and less than 32 characters`);
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return deviceId;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
validateHostname(hostname) {
|
|
154
|
+
if (!hostname) {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Basic hostname validation: letters, digits, dots, hyphens
|
|
159
|
+
const hostnameRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
|
160
|
+
|
|
161
|
+
if (!hostnameRegex.test(hostname)) {
|
|
162
|
+
this.log.error(`Invalid API server hostname: "${hostname}"`);
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return hostname;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// === STORAGE METHODS ===
|
|
170
|
+
|
|
171
|
+
async loadStoredCredentials() {
|
|
172
|
+
try {
|
|
173
|
+
if (fs.existsSync(this.storage)) {
|
|
174
|
+
const data = JSON.parse(fs.readFileSync(this.storage, 'utf8'));
|
|
175
|
+
|
|
176
|
+
// ALWAYS prefer stored credentials over config (storage is more up-to-date)
|
|
177
|
+
// Storage gets updated on auto-login and token refresh
|
|
178
|
+
// Note: We still validate stored credentials for safety, but accept them if valid
|
|
179
|
+
if (data.bearerToken) {
|
|
180
|
+
const validToken = this.validateToken(data.bearerToken, 'stored bearerToken');
|
|
181
|
+
if (validToken) {
|
|
182
|
+
this.bearerToken = validToken;
|
|
183
|
+
this.log.info('Loaded stored API token');
|
|
184
|
+
} else {
|
|
185
|
+
this.log.warn('Stored API token is invalid, ignoring');
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (data.deviceId) {
|
|
190
|
+
const validDeviceId = this.validateDeviceId(data.deviceId, 'stored deviceId');
|
|
191
|
+
if (validDeviceId) {
|
|
192
|
+
this.deviceId = validDeviceId;
|
|
193
|
+
this.log.info('Loaded stored device ID');
|
|
194
|
+
} else {
|
|
195
|
+
this.log.warn('Stored device ID is invalid, ignoring');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch (error) {
|
|
200
|
+
this.log.error('Failed to load stored credentials:', error.message);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async saveStoredCredentials() {
|
|
205
|
+
try {
|
|
206
|
+
const data = {
|
|
207
|
+
bearerToken: this.bearerToken,
|
|
208
|
+
deviceId: this.deviceId,
|
|
209
|
+
lastUpdated: new Date().toISOString()
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
fs.writeFileSync(this.storage, JSON.stringify(data, null, 2));
|
|
213
|
+
this.log.info('Saved credentials to storage');
|
|
214
|
+
} catch (error) {
|
|
215
|
+
this.log.error('Failed to save API token and device ID:', error.message);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async initialize() {
|
|
220
|
+
try {
|
|
221
|
+
// Check if using manual token mode (token required, deviceId optional)
|
|
222
|
+
if (this.bearerToken) {
|
|
223
|
+
this.log.info('Using configured API token');
|
|
224
|
+
this.currentToken = this.bearerToken;
|
|
225
|
+
|
|
226
|
+
// Auto-discover device if not provided
|
|
227
|
+
if (!this.deviceId) {
|
|
228
|
+
this.log.warn('Discovering device ID');
|
|
229
|
+
await this.autoDiscoverDevice();
|
|
230
|
+
|
|
231
|
+
if (!this.deviceId) {
|
|
232
|
+
this.log.error('No device ID found! Please ensure your coop door is connected to your Omlet account and try again.');
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Create accessories
|
|
238
|
+
await this.discoverDevices();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Auto-login mode (requires email + password)
|
|
243
|
+
if (!this.email || !this.password) {
|
|
244
|
+
this.log.error('Enter email address & password to configure plugin');
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
this.log.info('Logging into Omlet API');
|
|
249
|
+
await this.login();
|
|
250
|
+
|
|
251
|
+
// Auto-discover device if not provided
|
|
252
|
+
if (!this.deviceId) {
|
|
253
|
+
this.log.warn('Discovering device ID');
|
|
254
|
+
await this.autoDiscoverDevice();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!this.deviceId) {
|
|
258
|
+
this.log.error('No device ID found! Please ensure your coop door is connected to your Omlet account and try again.');
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Discover accessories
|
|
263
|
+
await this.discoverDevices();
|
|
264
|
+
|
|
265
|
+
} catch (error) {
|
|
266
|
+
this.log.error('Initialization failed:', error.message);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async login() {
|
|
271
|
+
try {
|
|
272
|
+
const apiKey = await this.performLogin();
|
|
273
|
+
|
|
274
|
+
this.currentToken = apiKey;
|
|
275
|
+
this.bearerToken = apiKey; // Update the config value too
|
|
276
|
+
|
|
277
|
+
// Save the token to storage
|
|
278
|
+
await this.saveStoredCredentials();
|
|
279
|
+
|
|
280
|
+
this.log.info('Login successful');
|
|
281
|
+
|
|
282
|
+
return apiKey;
|
|
283
|
+
|
|
284
|
+
} catch (error) {
|
|
285
|
+
if (error.statusCode === 401 || error.statusCode === 403) {
|
|
286
|
+
this.log.error('Login failed. Please check credentials and try again.');
|
|
287
|
+
} else {
|
|
288
|
+
this.log.error('Login failed:', error.message);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
performLogin() {
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
const postData = JSON.stringify({
|
|
298
|
+
emailAddress: this.email,
|
|
299
|
+
password: this.password,
|
|
300
|
+
cc: this.countryCode
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const options = {
|
|
304
|
+
hostname: this.baseUrl,
|
|
305
|
+
port: 443,
|
|
306
|
+
path: '/api/v1/login',
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers: {
|
|
309
|
+
'Content-Type': 'application/json',
|
|
310
|
+
'Content-Length': postData.length,
|
|
311
|
+
'Accept': 'application/json'
|
|
312
|
+
},
|
|
313
|
+
timeout: 10000
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
if (this.debug) {
|
|
317
|
+
this.log.info('[Auth] POST /api/v1/login');
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const req = https.request(options, (res) => {
|
|
321
|
+
let data = '';
|
|
322
|
+
|
|
323
|
+
res.on('data', (chunk) => {
|
|
324
|
+
data += chunk;
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
res.on('end', () => {
|
|
328
|
+
if (this.debug) {
|
|
329
|
+
this.log.info('[Auth] Response status:', res.statusCode);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (res.statusCode === 200) {
|
|
333
|
+
try {
|
|
334
|
+
const json = JSON.parse(data);
|
|
335
|
+
if (json.apiKey) {
|
|
336
|
+
resolve(json.apiKey);
|
|
337
|
+
} else {
|
|
338
|
+
reject(new Error('No apiKey in response'));
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
reject(new Error('Failed to parse login response'));
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
const error = new Error(`HTTP ${res.statusCode}`);
|
|
345
|
+
error.statusCode = res.statusCode;
|
|
346
|
+
error.response = data;
|
|
347
|
+
reject(error);
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
req.on('timeout', () => {
|
|
353
|
+
req.destroy();
|
|
354
|
+
reject(new Error('Login request timeout'));
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
req.on('error', (error) => {
|
|
358
|
+
reject(error);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
req.write(postData);
|
|
362
|
+
req.end();
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
async autoDiscoverDevice() {
|
|
367
|
+
try {
|
|
368
|
+
this.log.info('Discovering devices on your account...');
|
|
369
|
+
|
|
370
|
+
const devices = await this.discoverAllDevices();
|
|
371
|
+
|
|
372
|
+
if (devices.length === 0) {
|
|
373
|
+
this.log.warn('No devices found on your account');
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (devices.length === 1) {
|
|
378
|
+
this.deviceId = devices[0].deviceId;
|
|
379
|
+
|
|
380
|
+
// Save the deviceId to storage
|
|
381
|
+
await this.saveStoredCredentials();
|
|
382
|
+
|
|
383
|
+
this.log.info('✓ Auto-discovered device:', devices[0].name, '(', this.deviceId, ')');
|
|
384
|
+
this.log.info('✓ Device ID saved to storage');
|
|
385
|
+
} else {
|
|
386
|
+
this.log.warn('Multiple devices found on your account:');
|
|
387
|
+
devices.forEach((device, index) => {
|
|
388
|
+
this.log.warn(` ${index + 1}. ${device.name} (${device.deviceId})`);
|
|
389
|
+
});
|
|
390
|
+
this.log.warn('→ Please add one to your config.json: "deviceId": "DEVICE_ID_HERE"');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
} catch (error) {
|
|
394
|
+
this.log.error('Device discovery failed:', error.message);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
discoverAllDevices() {
|
|
399
|
+
return new Promise((resolve, reject) => {
|
|
400
|
+
const options = {
|
|
401
|
+
hostname: this.baseUrl,
|
|
402
|
+
port: 443,
|
|
403
|
+
path: '/api/v1/group',
|
|
404
|
+
method: 'GET',
|
|
405
|
+
headers: {
|
|
406
|
+
'Authorization': `Bearer ${this.currentToken}`,
|
|
407
|
+
'Accept': 'application/json'
|
|
408
|
+
},
|
|
409
|
+
timeout: 10000
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
if (this.debug) {
|
|
413
|
+
this.log.info('[Discovery] GET /api/v1/group');
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
const req = https.request(options, (res) => {
|
|
417
|
+
let data = '';
|
|
418
|
+
|
|
419
|
+
res.on('data', (chunk) => {
|
|
420
|
+
data += chunk;
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
res.on('end', () => {
|
|
424
|
+
if (res.statusCode === 200) {
|
|
425
|
+
try {
|
|
426
|
+
const json = JSON.parse(data);
|
|
427
|
+
const devices = [];
|
|
428
|
+
|
|
429
|
+
// Extract devices from groups
|
|
430
|
+
if (json.groups && Array.isArray(json.groups)) {
|
|
431
|
+
json.groups.forEach(group => {
|
|
432
|
+
if (group.devices && Array.isArray(group.devices)) {
|
|
433
|
+
group.devices.forEach(device => {
|
|
434
|
+
devices.push({
|
|
435
|
+
deviceId: device.deviceId,
|
|
436
|
+
name: device.name || 'Omlet Device',
|
|
437
|
+
type: device.deviceType || 'unknown'
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
resolve(devices);
|
|
445
|
+
} catch (error) {
|
|
446
|
+
reject(new Error('Failed to parse device list'));
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
reject(new Error(`HTTP ${res.statusCode}`));
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
req.on('timeout', () => {
|
|
455
|
+
req.destroy();
|
|
456
|
+
reject(new Error('Request timeout'));
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
req.on('error', (error) => {
|
|
460
|
+
reject(error);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
req.end();
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async handleAuthError() {
|
|
468
|
+
// If auth already failed 3 times, don't retry - just show "No Response" in HomeKit
|
|
469
|
+
if (this.authFailedPermanently) {
|
|
470
|
+
throw new Error('Authentication permanently failed - restart Homebridge after fixing credentials');
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
this.reloginAttempts++;
|
|
474
|
+
this.log.warn(`Authentication error detected, attempting to re-login (attempt ${this.reloginAttempts}/${this.maxReloginAttempts})...`);
|
|
475
|
+
|
|
476
|
+
try {
|
|
477
|
+
await this.login();
|
|
478
|
+
this.log.info('Re-login successful');
|
|
479
|
+
|
|
480
|
+
// Reset counter on success
|
|
481
|
+
this.reloginAttempts = 0;
|
|
482
|
+
|
|
483
|
+
return true;
|
|
484
|
+
} catch (error) {
|
|
485
|
+
this.log.error('Failed to re-login:', error.message);
|
|
486
|
+
|
|
487
|
+
if (this.reloginAttempts >= this.maxReloginAttempts) {
|
|
488
|
+
this.log.error(`Re-login failed ${this.maxReloginAttempts} times. Accessory will show "No Response" until Homebridge is restarted with valid credentials.`);
|
|
489
|
+
|
|
490
|
+
// Mark auth as permanently failed - all future operations will throw errors
|
|
491
|
+
// causing HomeKit to show "No Response"
|
|
492
|
+
this.authFailedPermanently = true;
|
|
493
|
+
} else {
|
|
494
|
+
this.log.warn(`Will retry on next operation (${this.maxReloginAttempts - this.reloginAttempts} attempts remaining)`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return false;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
async discoverDevices() {
|
|
502
|
+
this.log.info('Setting up Homebridge accessories...');
|
|
503
|
+
|
|
504
|
+
// Create ONE accessory with multiple services (linked services pattern)
|
|
505
|
+
const uuid = this.api.hap.uuid.generate('omlet-coop-' + this.deviceId);
|
|
506
|
+
const existingAccessory = this.accessories.find(accessory => accessory.UUID === uuid);
|
|
507
|
+
|
|
508
|
+
if (existingAccessory) {
|
|
509
|
+
this.log.info('Restoring existing accessory from cache:', existingAccessory.displayName);
|
|
510
|
+
new OmletCoopAccessory(this, existingAccessory);
|
|
511
|
+
} else {
|
|
512
|
+
this.log.info('Adding new accessory: Omlet Coop');
|
|
513
|
+
const coopAccessory = new this.api.platformAccessory('Omlet Coop', uuid);
|
|
514
|
+
new OmletCoopAccessory(this, coopAccessory);
|
|
515
|
+
this.api.registerPlatformAccessories('homebridge-omlet', 'OmletCoop', [coopAccessory]);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
configureAccessory(accessory) {
|
|
520
|
+
this.log.info('Loading accessory from cache:', accessory.displayName);
|
|
521
|
+
this.accessories.push(accessory);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
getCurrentToken() {
|
|
525
|
+
return this.currentToken;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Combined accessory with linked services
|
|
530
|
+
class OmletCoopAccessory {
|
|
531
|
+
constructor(platform, accessory) {
|
|
532
|
+
this.platform = platform;
|
|
533
|
+
this.accessory = accessory;
|
|
534
|
+
this.log = platform.log;
|
|
535
|
+
|
|
536
|
+
this.deviceId = platform.deviceId;
|
|
537
|
+
this.baseUrl = platform.baseUrl;
|
|
538
|
+
this.pollInterval = platform.pollInterval;
|
|
539
|
+
this.enableLight = platform.enableLight;
|
|
540
|
+
this.enableBattery = platform.enableBattery;
|
|
541
|
+
this.debug = platform.debug;
|
|
542
|
+
|
|
543
|
+
this.accessoryInfoUpdated = false; // Track if we've updated serial/firmware yet
|
|
544
|
+
|
|
545
|
+
// IMPORTANT: Remove services FIRST if disabled (before setting up anything else)
|
|
546
|
+
if (!this.enableLight) {
|
|
547
|
+
const existingLight = this.accessory.getService(hap.Service.Lightbulb);
|
|
548
|
+
if (existingLight) {
|
|
549
|
+
this.log.warn('Light disabled in config, removing light service...');
|
|
550
|
+
this.accessory.removeService(existingLight);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (!this.enableBattery) {
|
|
555
|
+
const existingBattery = this.accessory.getService(hap.Service.Battery);
|
|
556
|
+
if (existingBattery) {
|
|
557
|
+
this.log.warn('Battery disabled in config, removing battery service...');
|
|
558
|
+
this.accessory.removeService(existingBattery);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Set accessory information (will be updated with real serial/firmware after first poll)
|
|
563
|
+
this.accessory.getService(hap.Service.AccessoryInformation)
|
|
564
|
+
.setCharacteristic(hap.Characteristic.Manufacturer, 'Omlet')
|
|
565
|
+
.setCharacteristic(hap.Characteristic.Model, 'Smart Autodoor')
|
|
566
|
+
.setCharacteristic(hap.Characteristic.SerialNumber, this.deviceId) // Temporary, will update with deviceSerial
|
|
567
|
+
.setCharacteristic(hap.Characteristic.FirmwareRevision, '0.0.0'); // Temporary, will update with actual firmware
|
|
568
|
+
|
|
569
|
+
// Create Door Service (PRIMARY SERVICE)
|
|
570
|
+
this.doorService = this.accessory.getService(hap.Service.GarageDoorOpener)
|
|
571
|
+
|| this.accessory.addService(hap.Service.GarageDoorOpener);
|
|
572
|
+
|
|
573
|
+
this.doorService.setCharacteristic(hap.Characteristic.Name, 'Coop Door');
|
|
574
|
+
this.doorService.setPrimaryService(true); // Door is the primary service
|
|
575
|
+
|
|
576
|
+
// Set up door characteristics
|
|
577
|
+
this.doorService
|
|
578
|
+
.getCharacteristic(hap.Characteristic.CurrentDoorState)
|
|
579
|
+
.onGet(this.getCurrentDoorState.bind(this));
|
|
580
|
+
|
|
581
|
+
this.doorService
|
|
582
|
+
.getCharacteristic(hap.Characteristic.TargetDoorState)
|
|
583
|
+
.onGet(this.getTargetDoorState.bind(this))
|
|
584
|
+
.onSet(this.setTargetDoorState.bind(this));
|
|
585
|
+
|
|
586
|
+
this.doorService
|
|
587
|
+
.getCharacteristic(hap.Characteristic.ObstructionDetected)
|
|
588
|
+
.onGet(() => false);
|
|
589
|
+
|
|
590
|
+
// Create Light Service (LINKED SERVICE) - only if enabled
|
|
591
|
+
if (this.enableLight) {
|
|
592
|
+
this.lightService = this.accessory.getService(hap.Service.Lightbulb)
|
|
593
|
+
|| this.accessory.addService(hap.Service.Lightbulb);
|
|
594
|
+
|
|
595
|
+
this.lightService.setCharacteristic(hap.Characteristic.Name, 'Coop Light');
|
|
596
|
+
|
|
597
|
+
// Set up light characteristics
|
|
598
|
+
this.lightService
|
|
599
|
+
.getCharacteristic(hap.Characteristic.On)
|
|
600
|
+
.onGet(this.getLightOn.bind(this))
|
|
601
|
+
.onSet(this.setLightOn.bind(this));
|
|
602
|
+
|
|
603
|
+
// Link light service TO the door service (door is primary)
|
|
604
|
+
this.doorService.addLinkedService(this.lightService);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Create Battery Service (LINKED SERVICE) - only if enabled
|
|
608
|
+
if (this.enableBattery) {
|
|
609
|
+
this.batteryService = this.accessory.getService(hap.Service.Battery)
|
|
610
|
+
|| this.accessory.addService(hap.Service.Battery);
|
|
611
|
+
|
|
612
|
+
this.batteryService.setCharacteristic(hap.Characteristic.Name, 'Battery');
|
|
613
|
+
|
|
614
|
+
// Set up battery characteristics (all required by HAP spec)
|
|
615
|
+
this.batteryService
|
|
616
|
+
.getCharacteristic(hap.Characteristic.BatteryLevel)
|
|
617
|
+
.onGet(this.getBatteryLevel.bind(this));
|
|
618
|
+
|
|
619
|
+
this.batteryService
|
|
620
|
+
.getCharacteristic(hap.Characteristic.ChargingState)
|
|
621
|
+
.onGet(this.getChargingState.bind(this));
|
|
622
|
+
|
|
623
|
+
this.batteryService
|
|
624
|
+
.getCharacteristic(hap.Characteristic.StatusLowBattery)
|
|
625
|
+
.onGet(this.getStatusLowBattery.bind(this));
|
|
626
|
+
|
|
627
|
+
// Link battery service TO the door service (door is primary)
|
|
628
|
+
this.doorService.addLinkedService(this.batteryService);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Log initialization summary
|
|
632
|
+
const services = ['door'];
|
|
633
|
+
if (this.enableLight) services.push('light');
|
|
634
|
+
if (this.enableBattery) services.push('battery');
|
|
635
|
+
this.log.info(`Coop accessory initialized with ${services.join(', ')} service${services.length > 1 ? 's' : ''}`);
|
|
636
|
+
|
|
637
|
+
// Start polling
|
|
638
|
+
this.startPolling();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// === LIGHT METHODS ===
|
|
642
|
+
|
|
643
|
+
async getLightOn() {
|
|
644
|
+
try {
|
|
645
|
+
if (this.debug) {
|
|
646
|
+
this.log.info('[Light] Getting current state...');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const status = await this.getDeviceStatus('Light');
|
|
650
|
+
|
|
651
|
+
// Validate API response structure
|
|
652
|
+
if (!status?.state?.light?.state) {
|
|
653
|
+
this.log.error('[Light] Invalid API response: missing light state');
|
|
654
|
+
throw new Error('Invalid API response: missing light state');
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const lightState = status.state.light.state;
|
|
658
|
+
const isOn = (lightState === 'on' || lightState === 'onpending');
|
|
659
|
+
|
|
660
|
+
if (this.debug) {
|
|
661
|
+
this.log.info('[Light] Current state:', lightState, '-> isOn:', isOn);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return isOn;
|
|
665
|
+
} catch (error) {
|
|
666
|
+
this.log.error('[Light] Failed to get light state:', error.message);
|
|
667
|
+
|
|
668
|
+
// Check if it's an auth error
|
|
669
|
+
if (error.statusCode === 401 || error.statusCode === 403) {
|
|
670
|
+
const refreshed = await this.platform.handleAuthError();
|
|
671
|
+
if (refreshed) {
|
|
672
|
+
// Retry once with new token
|
|
673
|
+
try {
|
|
674
|
+
const status = await this.getDeviceStatus('Light');
|
|
675
|
+
if (!status?.state?.light?.state) {
|
|
676
|
+
throw new Error('Invalid API response: missing light state');
|
|
677
|
+
}
|
|
678
|
+
const lightState = status.state.light.state;
|
|
679
|
+
return (lightState === 'on' || lightState === 'onpending');
|
|
680
|
+
} catch (retryError) {
|
|
681
|
+
this.log.error('[Light] Retry after token refresh also failed');
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
throw new Error('Failed to get light state');
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
async setLightOn(value) {
|
|
691
|
+
const action = value ? 'on' : 'off';
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
if (this.debug) {
|
|
695
|
+
this.log.info('[Light] Setting state to:', action);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
await this.sendAction(action, 'Light');
|
|
699
|
+
this.log.info('[Light] Successfully turned', action);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
this.log.error('[Light] Failed to set light state:', error.message);
|
|
702
|
+
|
|
703
|
+
// Check if it's an auth error
|
|
704
|
+
if (error.statusCode === 401 || error.statusCode === 403) {
|
|
705
|
+
const refreshed = await this.platform.handleAuthError();
|
|
706
|
+
if (refreshed) {
|
|
707
|
+
// Retry once with new token
|
|
708
|
+
try {
|
|
709
|
+
await this.sendAction(action, 'Light');
|
|
710
|
+
this.log.info('[Light] Successfully turned', action, 'after token refresh');
|
|
711
|
+
return;
|
|
712
|
+
} catch (retryError) {
|
|
713
|
+
this.log.error('[Light] Retry after token refresh also failed');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
throw new Error('Failed to set light state');
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// === BATTERY METHODS ===
|
|
723
|
+
|
|
724
|
+
async getBatteryLevel() {
|
|
725
|
+
try {
|
|
726
|
+
if (this.debug) {
|
|
727
|
+
this.log.info('[Battery] Getting battery level...');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const status = await this.getDeviceStatus('Battery');
|
|
731
|
+
|
|
732
|
+
// Validate API response structure
|
|
733
|
+
if (status?.state?.general?.batteryLevel === undefined || status?.state?.general?.batteryLevel === null) {
|
|
734
|
+
this.log.error('[Battery] Invalid API response: missing battery level');
|
|
735
|
+
throw new Error('Invalid API response: missing battery level');
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const batteryLevel = status.state.general.batteryLevel;
|
|
739
|
+
|
|
740
|
+
if (this.debug) {
|
|
741
|
+
this.log.info('[Battery] Battery level:', batteryLevel + '%');
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
return batteryLevel;
|
|
745
|
+
} catch (error) {
|
|
746
|
+
this.log.error('[Battery] Failed to get battery level:', error.message);
|
|
747
|
+
throw new Error('Failed to get battery level');
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async getChargingState() {
|
|
752
|
+
try {
|
|
753
|
+
if (this.debug) {
|
|
754
|
+
this.log.info('[Battery] Getting charging state...');
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// ChargingState values:
|
|
758
|
+
// 0 = NOT_CHARGING
|
|
759
|
+
// 1 = CHARGING
|
|
760
|
+
// 2 = NOT_CHARGEABLE
|
|
761
|
+
//
|
|
762
|
+
// Omlet coops use AA batteries (not rechargeable), so always return NOT_CHARGEABLE
|
|
763
|
+
const chargingState = 2;
|
|
764
|
+
|
|
765
|
+
if (this.debug) {
|
|
766
|
+
this.log.info('[Battery] ChargingState: 2 (NOT_CHARGEABLE - AA batteries)');
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return chargingState;
|
|
770
|
+
} catch (error) {
|
|
771
|
+
this.log.error('[Battery] Failed to get charging state:', error.message);
|
|
772
|
+
throw new Error('Failed to get charging state');
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
async getStatusLowBattery() {
|
|
777
|
+
try {
|
|
778
|
+
if (this.debug) {
|
|
779
|
+
this.log.info('[Battery] Getting low battery status...');
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const status = await this.getDeviceStatus('Battery');
|
|
783
|
+
|
|
784
|
+
// Validate API response structure
|
|
785
|
+
if (status?.state?.general?.batteryLevel === undefined || status?.state?.general?.batteryLevel === null) {
|
|
786
|
+
this.log.error('[Battery] Invalid API response: missing battery level');
|
|
787
|
+
throw new Error('Invalid API response: missing battery level');
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const batteryLevel = status.state.general.batteryLevel;
|
|
791
|
+
|
|
792
|
+
// StatusLowBattery values:
|
|
793
|
+
// 0 = BATTERY_LEVEL_NORMAL
|
|
794
|
+
// 1 = BATTERY_LEVEL_LOW
|
|
795
|
+
const isLow = (batteryLevel < 20) ? 1 : 0;
|
|
796
|
+
|
|
797
|
+
if (this.debug) {
|
|
798
|
+
this.log.info('[Battery] Battery level:', batteryLevel + '% -> StatusLowBattery:', isLow);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
return isLow;
|
|
802
|
+
} catch (error) {
|
|
803
|
+
this.log.error('[Battery] Failed to get low battery status:', error.message);
|
|
804
|
+
throw new Error('Failed to get low battery status');
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
sendAction(action, context = 'Action') {
|
|
809
|
+
return new Promise((resolve, reject) => {
|
|
810
|
+
const postData = JSON.stringify({});
|
|
811
|
+
const token = this.platform.getCurrentToken();
|
|
812
|
+
|
|
813
|
+
if (!token) {
|
|
814
|
+
reject(new Error('No auth token available'));
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const options = {
|
|
819
|
+
hostname: this.baseUrl,
|
|
820
|
+
port: 443,
|
|
821
|
+
path: `/api/v1/device/${this.deviceId}/action/${action}`,
|
|
822
|
+
method: 'POST',
|
|
823
|
+
headers: {
|
|
824
|
+
'Authorization': `Bearer ${token}`,
|
|
825
|
+
'Content-Type': 'application/json',
|
|
826
|
+
'Content-Length': postData.length,
|
|
827
|
+
'Accept': 'application/json'
|
|
828
|
+
},
|
|
829
|
+
timeout: 10000
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
if (this.debug) {
|
|
833
|
+
this.log.info(`[${context}] POST`, options.path);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
const req = https.request(options, (res) => {
|
|
837
|
+
let data = '';
|
|
838
|
+
|
|
839
|
+
res.on('data', (chunk) => {
|
|
840
|
+
data += chunk;
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
res.on('end', () => {
|
|
844
|
+
if (this.debug) {
|
|
845
|
+
this.log.info(`[${context}] Response status:`, res.statusCode);
|
|
846
|
+
if (data) {
|
|
847
|
+
this.log.info(`[${context}] Response body:`, data);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
if (res.statusCode === 200 || res.statusCode === 204) {
|
|
852
|
+
resolve();
|
|
853
|
+
} else {
|
|
854
|
+
const error = new Error(`HTTP ${res.statusCode}`);
|
|
855
|
+
error.statusCode = res.statusCode;
|
|
856
|
+
error.response = data;
|
|
857
|
+
reject(error);
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
req.on('timeout', () => {
|
|
863
|
+
req.destroy();
|
|
864
|
+
this.log.error(`[${context}] Request timeout after 10 seconds`);
|
|
865
|
+
reject(new Error('Request timeout'));
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
req.on('error', (error) => {
|
|
869
|
+
this.log.error(`[${context}] Network error:`, error.message);
|
|
870
|
+
reject(error);
|
|
871
|
+
});
|
|
872
|
+
|
|
873
|
+
req.write(postData);
|
|
874
|
+
req.end();
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
getDeviceStatus(context = 'Status') {
|
|
879
|
+
return new Promise((resolve, reject) => {
|
|
880
|
+
const token = this.platform.getCurrentToken();
|
|
881
|
+
|
|
882
|
+
if (!token) {
|
|
883
|
+
reject(new Error('No auth token available'));
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
const options = {
|
|
888
|
+
hostname: this.baseUrl,
|
|
889
|
+
port: 443,
|
|
890
|
+
path: `/api/v1/device/${this.deviceId}`,
|
|
891
|
+
method: 'GET',
|
|
892
|
+
headers: {
|
|
893
|
+
'Authorization': `Bearer ${token}`,
|
|
894
|
+
'Accept': 'application/json'
|
|
895
|
+
},
|
|
896
|
+
timeout: 10000
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
if (this.debug) {
|
|
900
|
+
this.log.info(`[${context}] GET`, options.path);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const req = https.request(options, (res) => {
|
|
904
|
+
let data = '';
|
|
905
|
+
|
|
906
|
+
res.on('data', (chunk) => {
|
|
907
|
+
data += chunk;
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
res.on('end', () => {
|
|
911
|
+
if (this.debug) {
|
|
912
|
+
this.log.info(`[${context}] Response status:`, res.statusCode);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (res.statusCode === 200) {
|
|
916
|
+
try {
|
|
917
|
+
const json = JSON.parse(data);
|
|
918
|
+
resolve(json);
|
|
919
|
+
} catch (error) {
|
|
920
|
+
this.log.error(`[${context}] Failed to parse JSON:`, error.message);
|
|
921
|
+
this.log.error(`[${context}] Response was:`, data);
|
|
922
|
+
reject(new Error('Failed to parse JSON response'));
|
|
923
|
+
}
|
|
924
|
+
} else {
|
|
925
|
+
if (this.debug || res.statusCode === 401 || res.statusCode === 403) {
|
|
926
|
+
this.log.error(`[${context}] HTTP Error`, res.statusCode);
|
|
927
|
+
this.log.error(`[${context}] Response:`, data);
|
|
928
|
+
}
|
|
929
|
+
const error = new Error(`HTTP ${res.statusCode}`);
|
|
930
|
+
error.statusCode = res.statusCode;
|
|
931
|
+
error.response = data;
|
|
932
|
+
reject(error);
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
req.on('timeout', () => {
|
|
938
|
+
req.destroy();
|
|
939
|
+
this.log.error(`[${context}] Request timeout after 10 seconds`);
|
|
940
|
+
reject(new Error('Request timeout'));
|
|
941
|
+
});
|
|
942
|
+
|
|
943
|
+
req.on('error', (error) => {
|
|
944
|
+
this.log.error(`[${context}] Network error:`, error.message);
|
|
945
|
+
reject(error);
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
req.end();
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// === DOOR METHODS ===
|
|
953
|
+
|
|
954
|
+
async getCurrentDoorState() {
|
|
955
|
+
try {
|
|
956
|
+
if (this.debug) {
|
|
957
|
+
this.log.info('[Door] Getting current state...');
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const status = await this.getDeviceStatus('Door');
|
|
961
|
+
|
|
962
|
+
// Validate API response structure
|
|
963
|
+
if (!status?.state?.door?.state) {
|
|
964
|
+
this.log.error('[Door] Invalid API response: missing door state');
|
|
965
|
+
throw new Error('Invalid API response: missing door state');
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const doorState = status.state.door.state;
|
|
969
|
+
|
|
970
|
+
// Map API states to HomeKit states
|
|
971
|
+
const stateMap = {
|
|
972
|
+
'open': hap.Characteristic.CurrentDoorState.OPEN,
|
|
973
|
+
'closed': hap.Characteristic.CurrentDoorState.CLOSED,
|
|
974
|
+
'opening': hap.Characteristic.CurrentDoorState.OPENING,
|
|
975
|
+
'closing': hap.Characteristic.CurrentDoorState.CLOSING,
|
|
976
|
+
'stopping': hap.Characteristic.CurrentDoorState.STOPPED
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
const currentState = stateMap[doorState] ?? hap.Characteristic.CurrentDoorState.STOPPED;
|
|
980
|
+
|
|
981
|
+
if (this.debug) {
|
|
982
|
+
this.log.info('[Door] Current state:', doorState, '-> HomeKit:', currentState);
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
return currentState;
|
|
986
|
+
} catch (error) {
|
|
987
|
+
this.log.error('[Door] Failed to get door state:', error.message);
|
|
988
|
+
|
|
989
|
+
// Check if it's an auth error
|
|
990
|
+
if (error.statusCode === 401 || error.statusCode === 403) {
|
|
991
|
+
const refreshed = await this.platform.handleAuthError();
|
|
992
|
+
if (refreshed) {
|
|
993
|
+
// Retry once with new token
|
|
994
|
+
try {
|
|
995
|
+
const status = await this.getDeviceStatus('Door');
|
|
996
|
+
if (!status?.state?.door?.state) {
|
|
997
|
+
throw new Error('Invalid API response: missing door state');
|
|
998
|
+
}
|
|
999
|
+
const doorState = status.state.door.state;
|
|
1000
|
+
const stateMap = {
|
|
1001
|
+
'open': hap.Characteristic.CurrentDoorState.OPEN,
|
|
1002
|
+
'closed': hap.Characteristic.CurrentDoorState.CLOSED,
|
|
1003
|
+
'opening': hap.Characteristic.CurrentDoorState.OPENING,
|
|
1004
|
+
'closing': hap.Characteristic.CurrentDoorState.CLOSING,
|
|
1005
|
+
'stopping': hap.Characteristic.CurrentDoorState.STOPPED
|
|
1006
|
+
};
|
|
1007
|
+
return stateMap[doorState] ?? hap.Characteristic.CurrentDoorState.STOPPED;
|
|
1008
|
+
} catch (retryError) {
|
|
1009
|
+
this.log.error('[Door] Retry after token refresh also failed');
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
throw new Error('Failed to get door state');
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
async getTargetDoorState() {
|
|
1019
|
+
try {
|
|
1020
|
+
const currentState = await this.getCurrentDoorState();
|
|
1021
|
+
|
|
1022
|
+
if (currentState === hap.Characteristic.CurrentDoorState.OPEN ||
|
|
1023
|
+
currentState === hap.Characteristic.CurrentDoorState.OPENING) {
|
|
1024
|
+
return hap.Characteristic.TargetDoorState.OPEN;
|
|
1025
|
+
} else {
|
|
1026
|
+
return hap.Characteristic.TargetDoorState.CLOSED;
|
|
1027
|
+
}
|
|
1028
|
+
} catch (error) {
|
|
1029
|
+
this.log.error('[Door] Failed to get target door state:', error.message);
|
|
1030
|
+
throw new Error('Failed to get target door state');
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async setTargetDoorState(value) {
|
|
1035
|
+
const action = (value === hap.Characteristic.TargetDoorState.OPEN) ? 'open' : 'close';
|
|
1036
|
+
|
|
1037
|
+
try {
|
|
1038
|
+
if (this.debug) {
|
|
1039
|
+
this.log.info('[Door] Setting state to:', action);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
await this.sendAction(action, 'Door');
|
|
1043
|
+
this.log.info('[Door] Successfully sent command:', action);
|
|
1044
|
+
|
|
1045
|
+
const newCurrentState = (action === 'open')
|
|
1046
|
+
? hap.Characteristic.CurrentDoorState.OPENING
|
|
1047
|
+
: hap.Characteristic.CurrentDoorState.CLOSING;
|
|
1048
|
+
|
|
1049
|
+
this.doorService
|
|
1050
|
+
.getCharacteristic(hap.Characteristic.CurrentDoorState)
|
|
1051
|
+
.updateValue(newCurrentState);
|
|
1052
|
+
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
this.log.error('[Door] Failed to set door state:', error.message);
|
|
1055
|
+
|
|
1056
|
+
// Check if it's an auth error
|
|
1057
|
+
if (error.statusCode === 401 || error.statusCode === 403) {
|
|
1058
|
+
const refreshed = await this.platform.handleAuthError();
|
|
1059
|
+
if (refreshed) {
|
|
1060
|
+
// Retry once with new token
|
|
1061
|
+
try {
|
|
1062
|
+
await this.sendAction(action, 'Door');
|
|
1063
|
+
this.log.info('[Door] Successfully sent command:', action, 'after token refresh');
|
|
1064
|
+
|
|
1065
|
+
const newCurrentState = (action === 'open')
|
|
1066
|
+
? hap.Characteristic.CurrentDoorState.OPENING
|
|
1067
|
+
: hap.Characteristic.CurrentDoorState.CLOSING;
|
|
1068
|
+
|
|
1069
|
+
this.doorService
|
|
1070
|
+
.getCharacteristic(hap.Characteristic.CurrentDoorState)
|
|
1071
|
+
.updateValue(newCurrentState);
|
|
1072
|
+
|
|
1073
|
+
return;
|
|
1074
|
+
} catch (retryError) {
|
|
1075
|
+
this.log.error('[Door] Retry after token refresh also failed');
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
throw new Error('Failed to set door state');
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
// === SHARED API METHODS ===
|
|
1085
|
+
|
|
1086
|
+
sendAction(action) {
|
|
1087
|
+
return new Promise((resolve, reject) => {
|
|
1088
|
+
const postData = JSON.stringify({});
|
|
1089
|
+
const token = this.platform.getCurrentToken();
|
|
1090
|
+
|
|
1091
|
+
if (!token) {
|
|
1092
|
+
reject(new Error('No auth token available'));
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
const options = {
|
|
1097
|
+
hostname: this.baseUrl,
|
|
1098
|
+
port: 443,
|
|
1099
|
+
path: `/api/v1/device/${this.deviceId}/action/${action}`,
|
|
1100
|
+
method: 'POST',
|
|
1101
|
+
headers: {
|
|
1102
|
+
'Authorization': `Bearer ${token}`,
|
|
1103
|
+
'Content-Type': 'application/json',
|
|
1104
|
+
'Content-Length': postData.length,
|
|
1105
|
+
'Accept': 'application/json'
|
|
1106
|
+
},
|
|
1107
|
+
timeout: 10000
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
if (this.debug) {
|
|
1111
|
+
this.log.info('[Door] POST', options.path);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const req = https.request(options, (res) => {
|
|
1115
|
+
let data = '';
|
|
1116
|
+
|
|
1117
|
+
res.on('data', (chunk) => {
|
|
1118
|
+
data += chunk;
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
res.on('end', () => {
|
|
1122
|
+
if (this.debug) {
|
|
1123
|
+
this.log.info('[Door] Response status:', res.statusCode);
|
|
1124
|
+
if (data) {
|
|
1125
|
+
this.log.info('[Door] Response body:', data);
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (res.statusCode === 200 || res.statusCode === 204) {
|
|
1130
|
+
resolve();
|
|
1131
|
+
} else {
|
|
1132
|
+
const error = new Error(`HTTP ${res.statusCode}`);
|
|
1133
|
+
error.statusCode = res.statusCode;
|
|
1134
|
+
error.response = data;
|
|
1135
|
+
reject(error);
|
|
1136
|
+
}
|
|
1137
|
+
});
|
|
1138
|
+
});
|
|
1139
|
+
|
|
1140
|
+
req.on('timeout', () => {
|
|
1141
|
+
req.destroy();
|
|
1142
|
+
this.log.error('[Door] Request timeout after 10 seconds');
|
|
1143
|
+
reject(new Error('Request timeout'));
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
req.on('error', (error) => {
|
|
1147
|
+
this.log.error('[Door] Network error:', error.message);
|
|
1148
|
+
reject(error);
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
req.write(postData);
|
|
1152
|
+
req.end();
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
getDeviceStatus() {
|
|
1157
|
+
return new Promise((resolve, reject) => {
|
|
1158
|
+
const token = this.platform.getCurrentToken();
|
|
1159
|
+
|
|
1160
|
+
if (!token) {
|
|
1161
|
+
reject(new Error('No auth token available'));
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
const options = {
|
|
1166
|
+
hostname: this.baseUrl,
|
|
1167
|
+
port: 443,
|
|
1168
|
+
path: `/api/v1/device/${this.deviceId}`,
|
|
1169
|
+
method: 'GET',
|
|
1170
|
+
headers: {
|
|
1171
|
+
'Authorization': `Bearer ${token}`,
|
|
1172
|
+
'Accept': 'application/json'
|
|
1173
|
+
},
|
|
1174
|
+
timeout: 10000
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
if (this.debug) {
|
|
1178
|
+
this.log.info('[Door] GET', options.path);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
const req = https.request(options, (res) => {
|
|
1182
|
+
let data = '';
|
|
1183
|
+
|
|
1184
|
+
res.on('data', (chunk) => {
|
|
1185
|
+
data += chunk;
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
res.on('end', () => {
|
|
1189
|
+
if (this.debug) {
|
|
1190
|
+
this.log.info('[Door] Response status:', res.statusCode);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
if (res.statusCode === 200) {
|
|
1194
|
+
try {
|
|
1195
|
+
const json = JSON.parse(data);
|
|
1196
|
+
resolve(json);
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
this.log.error('[Door] Failed to parse JSON:', error.message);
|
|
1199
|
+
this.log.error('[Door] Response was:', data);
|
|
1200
|
+
reject(new Error('Failed to parse JSON response'));
|
|
1201
|
+
}
|
|
1202
|
+
} else {
|
|
1203
|
+
if (this.debug || res.statusCode === 401 || res.statusCode === 403) {
|
|
1204
|
+
this.log.error('[Door] HTTP Error', res.statusCode);
|
|
1205
|
+
this.log.error('[Door] Response:', data);
|
|
1206
|
+
}
|
|
1207
|
+
const error = new Error(`HTTP ${res.statusCode}`);
|
|
1208
|
+
error.statusCode = res.statusCode;
|
|
1209
|
+
error.response = data;
|
|
1210
|
+
reject(error);
|
|
1211
|
+
}
|
|
1212
|
+
});
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
req.on('timeout', () => {
|
|
1216
|
+
req.destroy();
|
|
1217
|
+
this.log.error('[Door] Request timeout after 10 seconds');
|
|
1218
|
+
reject(new Error('Request timeout'));
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
req.on('error', (error) => {
|
|
1222
|
+
this.log.error('[Door] Network error:', error.message);
|
|
1223
|
+
reject(error);
|
|
1224
|
+
});
|
|
1225
|
+
|
|
1226
|
+
req.end();
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
startPolling() {
|
|
1231
|
+
// Do an immediate first poll
|
|
1232
|
+
(async () => {
|
|
1233
|
+
try {
|
|
1234
|
+
const status = await this.getDeviceStatus('Info');
|
|
1235
|
+
|
|
1236
|
+
// Update accessory information with real serial number and firmware (first time only)
|
|
1237
|
+
if (!this.accessoryInfoUpdated) {
|
|
1238
|
+
const deviceSerial = status.deviceSerial || this.deviceId;
|
|
1239
|
+
const firmware = status.state?.general?.firmwareVersionCurrent || '0.0.0';
|
|
1240
|
+
|
|
1241
|
+
this.accessory.getService(hap.Service.AccessoryInformation)
|
|
1242
|
+
.setCharacteristic(hap.Characteristic.SerialNumber, deviceSerial)
|
|
1243
|
+
.setCharacteristic(hap.Characteristic.FirmwareRevision, firmware);
|
|
1244
|
+
|
|
1245
|
+
if (this.debug) {
|
|
1246
|
+
this.log.info('[Info] Updated accessory info: Serial=' + deviceSerial + ', Firmware=' + firmware);
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
this.accessoryInfoUpdated = true;
|
|
1250
|
+
}
|
|
1251
|
+
} catch (error) {
|
|
1252
|
+
this.log.error('First poll failed, will retry on next interval');
|
|
1253
|
+
}
|
|
1254
|
+
})();
|
|
1255
|
+
|
|
1256
|
+
setInterval(async () => {
|
|
1257
|
+
try {
|
|
1258
|
+
// Poll door state
|
|
1259
|
+
const currentState = await this.getCurrentDoorState();
|
|
1260
|
+
this.doorService
|
|
1261
|
+
.getCharacteristic(hap.Characteristic.CurrentDoorState)
|
|
1262
|
+
.updateValue(currentState);
|
|
1263
|
+
|
|
1264
|
+
const targetState = await this.getTargetDoorState();
|
|
1265
|
+
this.doorService
|
|
1266
|
+
.getCharacteristic(hap.Characteristic.TargetDoorState)
|
|
1267
|
+
.updateValue(targetState);
|
|
1268
|
+
|
|
1269
|
+
// Poll light state (only if enabled)
|
|
1270
|
+
if (this.enableLight && this.lightService) {
|
|
1271
|
+
const isOn = await this.getLightOn();
|
|
1272
|
+
this.lightService
|
|
1273
|
+
.getCharacteristic(hap.Characteristic.On)
|
|
1274
|
+
.updateValue(isOn);
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// Poll battery state (only if enabled)
|
|
1278
|
+
if (this.enableBattery && this.batteryService) {
|
|
1279
|
+
const batteryLevel = await this.getBatteryLevel();
|
|
1280
|
+
this.batteryService
|
|
1281
|
+
.getCharacteristic(hap.Characteristic.BatteryLevel)
|
|
1282
|
+
.updateValue(batteryLevel);
|
|
1283
|
+
|
|
1284
|
+
const chargingState = await this.getChargingState();
|
|
1285
|
+
this.batteryService
|
|
1286
|
+
.getCharacteristic(hap.Characteristic.ChargingState)
|
|
1287
|
+
.updateValue(chargingState);
|
|
1288
|
+
|
|
1289
|
+
const statusLowBattery = await this.getStatusLowBattery();
|
|
1290
|
+
this.batteryService
|
|
1291
|
+
.getCharacteristic(hap.Characteristic.StatusLowBattery)
|
|
1292
|
+
.updateValue(statusLowBattery);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
} catch (error) {
|
|
1296
|
+
// Individual getter methods already log their specific errors
|
|
1297
|
+
// This catches any unexpected errors (like updateValue failures)
|
|
1298
|
+
if (this.debug) {
|
|
1299
|
+
this.log.warn('[Poll] Unexpected error in poll cycle:', error.message);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
}, this.pollInterval);
|
|
1303
|
+
|
|
1304
|
+
const services = [];
|
|
1305
|
+
if (true) services.push('door'); // Door always enabled
|
|
1306
|
+
if (this.enableLight) services.push('light');
|
|
1307
|
+
if (this.enableBattery) services.push('battery');
|
|
1308
|
+
this.log.info(`Polling started for ${services.join(', ')}: every ${this.pollInterval / 1000} seconds`);
|
|
1309
|
+
}
|
|
1310
|
+
}
|