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/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
+ }