homebridge-myleviton 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +112 -0
  3. package/config.schema.json +136 -0
  4. package/dist/api/cache.d.ts +108 -0
  5. package/dist/api/cache.d.ts.map +1 -0
  6. package/dist/api/cache.js +206 -0
  7. package/dist/api/cache.js.map +1 -0
  8. package/dist/api/circuit-breaker.d.ts +118 -0
  9. package/dist/api/circuit-breaker.d.ts.map +1 -0
  10. package/dist/api/circuit-breaker.js +223 -0
  11. package/dist/api/circuit-breaker.js.map +1 -0
  12. package/dist/api/client.d.ts +116 -0
  13. package/dist/api/client.d.ts.map +1 -0
  14. package/dist/api/client.js +358 -0
  15. package/dist/api/client.js.map +1 -0
  16. package/dist/api/index.d.ts +23 -0
  17. package/dist/api/index.d.ts.map +1 -0
  18. package/dist/api/index.js +47 -0
  19. package/dist/api/index.js.map +1 -0
  20. package/dist/api/persistence.d.ts +107 -0
  21. package/dist/api/persistence.d.ts.map +1 -0
  22. package/dist/api/persistence.js +285 -0
  23. package/dist/api/persistence.js.map +1 -0
  24. package/dist/api/rate-limiter.d.ts +102 -0
  25. package/dist/api/rate-limiter.d.ts.map +1 -0
  26. package/dist/api/rate-limiter.js +173 -0
  27. package/dist/api/rate-limiter.js.map +1 -0
  28. package/dist/api/request-queue.d.ts +104 -0
  29. package/dist/api/request-queue.d.ts.map +1 -0
  30. package/dist/api/request-queue.js +223 -0
  31. package/dist/api/request-queue.js.map +1 -0
  32. package/dist/api/websocket.d.ts +116 -0
  33. package/dist/api/websocket.d.ts.map +1 -0
  34. package/dist/api/websocket.js +319 -0
  35. package/dist/api/websocket.js.map +1 -0
  36. package/dist/errors/index.d.ts +182 -0
  37. package/dist/errors/index.d.ts.map +1 -0
  38. package/dist/errors/index.js +273 -0
  39. package/dist/errors/index.js.map +1 -0
  40. package/dist/index.d.ts +16 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +42 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/platform.d.ts +139 -0
  45. package/dist/platform.d.ts.map +1 -0
  46. package/dist/platform.js +664 -0
  47. package/dist/platform.js.map +1 -0
  48. package/dist/types/index.d.ts +225 -0
  49. package/dist/types/index.d.ts.map +1 -0
  50. package/dist/types/index.js +34 -0
  51. package/dist/types/index.js.map +1 -0
  52. package/dist/utils/index.d.ts +15 -0
  53. package/dist/utils/index.d.ts.map +1 -0
  54. package/dist/utils/index.js +52 -0
  55. package/dist/utils/index.js.map +1 -0
  56. package/dist/utils/logger.d.ts +103 -0
  57. package/dist/utils/logger.d.ts.map +1 -0
  58. package/dist/utils/logger.js +184 -0
  59. package/dist/utils/logger.js.map +1 -0
  60. package/dist/utils/retry.d.ts +56 -0
  61. package/dist/utils/retry.d.ts.map +1 -0
  62. package/dist/utils/retry.js +141 -0
  63. package/dist/utils/retry.js.map +1 -0
  64. package/dist/utils/sanitizers.d.ts +37 -0
  65. package/dist/utils/sanitizers.d.ts.map +1 -0
  66. package/dist/utils/sanitizers.js +128 -0
  67. package/dist/utils/sanitizers.js.map +1 -0
  68. package/dist/utils/validators.d.ts +51 -0
  69. package/dist/utils/validators.d.ts.map +1 -0
  70. package/dist/utils/validators.js +243 -0
  71. package/dist/utils/validators.js.map +1 -0
  72. package/package.json +69 -0
@@ -0,0 +1,664 @@
1
+ "use strict";
2
+ /**
3
+ * Copyright (c) 2026 tbaur
4
+ *
5
+ * Licensed under the Apache License, Version 2.0
6
+ * See LICENSE file for full license text
7
+ *
8
+ * @fileoverview Homebridge platform plugin for My Leviton Decora Smart devices
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.LevitonDecoraSmartPlatform = void 0;
45
+ exports.registerPlatform = registerPlatform;
46
+ const path = __importStar(require("path"));
47
+ const client_1 = require("./api/client");
48
+ const websocket_1 = require("./api/websocket");
49
+ const persistence_1 = require("./api/persistence");
50
+ const logger_1 = require("./utils/logger");
51
+ const sanitizers_1 = require("./utils/sanitizers");
52
+ // Plugin constants
53
+ const PLUGIN_NAME = 'homebridge-myleviton';
54
+ const PLATFORM_NAME = 'MyLevitonDecoraSmart';
55
+ const UUID_PREFIX = 'myleviton-';
56
+ // Power states
57
+ const POWER_ON = 'ON';
58
+ const POWER_OFF = 'OFF';
59
+ // Device model arrays for type checking
60
+ const DIMMER_MODELS = ['DWVAA', 'DW1KD', 'DW6HD', 'D26HD', 'D23LP', 'DW3HL'];
61
+ const MOTION_DIMMER_MODELS = ['D2MSD'];
62
+ const OUTLET_MODELS = ['DW15R', 'DW15A', 'DW15P', 'D215P']; // D215P is plug-in switch
63
+ const SWITCH_MODELS = ['DW15S', 'D215S'];
64
+ const CONTROLLER_MODELS = ['DW4BC']; // Button controllers - no state, skip
65
+ const FAN_MODEL = 'DW4SF';
66
+ let hap;
67
+ /**
68
+ * Leviton Decora Smart Platform for Homebridge
69
+ */
70
+ class LevitonDecoraSmartPlatform {
71
+ config;
72
+ api;
73
+ accessories = [];
74
+ log;
75
+ // API client
76
+ client;
77
+ // Token management
78
+ currentToken = null;
79
+ tokenRefreshInProgress = false;
80
+ // WebSocket connection
81
+ webSocket = null;
82
+ // Polling
83
+ pollingInterval = null;
84
+ residenceId = null;
85
+ // Device persistence
86
+ devicePersistence;
87
+ // Cleanup interval
88
+ cleanupInterval = null;
89
+ constructor(homebridgeLog, config, api) {
90
+ this.config = config;
91
+ this.api = api;
92
+ // Setup logging
93
+ this.log = (0, logger_1.createLogger)(homebridgeLog, config?.loglevel || 'info');
94
+ // Setup API client
95
+ this.client = (0, client_1.getApiClient)({
96
+ timeout: config?.connectionTimeout || 10000,
97
+ });
98
+ // Setup device persistence
99
+ const storagePath = api?.user?.storagePath?.()
100
+ ? path.join(api.user.storagePath(), '.homebridge-myleviton-state.json')
101
+ : undefined;
102
+ this.devicePersistence = (0, persistence_1.getDevicePersistence)(storagePath);
103
+ // Validate configuration
104
+ if (!this.validateConfig()) {
105
+ return;
106
+ }
107
+ // Initialize on Homebridge launch
108
+ api.on('didFinishLaunching', async () => {
109
+ await this.initialize();
110
+ });
111
+ // Cleanup on shutdown
112
+ api.on('shutdown', () => {
113
+ this.saveDeviceStates();
114
+ this.cleanup();
115
+ });
116
+ // Start periodic cleanup
117
+ this.startPeriodicCleanup();
118
+ }
119
+ /**
120
+ * Validates plugin configuration
121
+ */
122
+ validateConfig() {
123
+ if (!this.config) {
124
+ this.log.error(`No config for ${PLUGIN_NAME} defined.`);
125
+ return false;
126
+ }
127
+ if (!this.config.email || !this.config.password) {
128
+ this.log.error(`email and password for ${PLUGIN_NAME} are required in config.json`);
129
+ return false;
130
+ }
131
+ if (typeof this.config.email !== 'string' || typeof this.config.password !== 'string') {
132
+ this.log.error('email and password must be strings');
133
+ return false;
134
+ }
135
+ // Validate email format
136
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
137
+ if (!emailRegex.test(this.config.email)) {
138
+ this.log.error(`Invalid email format: ${this.config.email}`);
139
+ return false;
140
+ }
141
+ return true;
142
+ }
143
+ /**
144
+ * Initializes the platform
145
+ */
146
+ async initialize() {
147
+ this.log.info('Starting My Leviton Decora Smart platform...');
148
+ try {
149
+ const { devices, token, residenceId } = await this.discoverDevices();
150
+ this.currentToken = token;
151
+ this.residenceId = residenceId;
152
+ if (devices.length === 0) {
153
+ this.log.error('No devices found in your My Leviton account');
154
+ return;
155
+ }
156
+ // Get exclusion lists
157
+ const excludedModels = (this.config.excludedModels || []).map(m => m.toUpperCase());
158
+ const excludedSerials = (this.config.excludedSerials || []).map(s => s.toUpperCase());
159
+ let newDevices = 0;
160
+ let excludedCount = 0;
161
+ let cachedCount = 0;
162
+ for (const device of devices) {
163
+ if (this.isDeviceExcluded(device, excludedModels, excludedSerials)) {
164
+ excludedCount++;
165
+ }
166
+ else if (this.accessoryExists(device)) {
167
+ cachedCount++;
168
+ }
169
+ else {
170
+ await this.addAccessory(device, token);
171
+ newDevices++;
172
+ }
173
+ }
174
+ this.log.info(`Found ${devices.length} devices (${cachedCount} cached, ${newDevices} new, ${excludedCount} excluded)`);
175
+ // Start polling
176
+ this.startPolling();
177
+ this.log.info('Platform ready');
178
+ }
179
+ catch (error) {
180
+ this.log.error(`Failed to initialize: ${(0, sanitizers_1.sanitizeError)(error)}`);
181
+ }
182
+ }
183
+ /**
184
+ * Discovers devices from Leviton API
185
+ */
186
+ async discoverDevices() {
187
+ const debugLog = (msg) => this.log.debug(msg);
188
+ // Login
189
+ this.log.info('Connecting to My Leviton...');
190
+ const login = await this.client.login(this.config.email, this.config.password, debugLog);
191
+ const token = login.id;
192
+ const personId = login.userId;
193
+ this.log.info('Authentication successful');
194
+ // Get residential permissions
195
+ this.log.info('Loading residence information...');
196
+ const permissions = await this.client.getResidentialPermissions(personId, token, debugLog);
197
+ if (!permissions.length || !permissions[0].residentialAccountId) {
198
+ throw new Error('No residential permissions found');
199
+ }
200
+ const accountId = permissions[0].residentialAccountId;
201
+ // Get residential account
202
+ const account = await this.client.getResidentialAccount(accountId, token, debugLog);
203
+ if (!account.primaryResidenceId || !account.id) {
204
+ throw new Error('Invalid residential account response');
205
+ }
206
+ let residenceId = account.primaryResidenceId;
207
+ const residenceObjectId = account.id;
208
+ // Get devices
209
+ this.log.info('Discovering devices...');
210
+ let devices = await this.client.getDevices(residenceId, token, debugLog);
211
+ // Try v2 API if no devices found
212
+ if (!devices.length) {
213
+ this.log.debug('Trying alternate residence API...');
214
+ const residences = await this.client.getResidences(residenceObjectId, token, debugLog);
215
+ if (residences.length && residences[0].id) {
216
+ residenceId = residences[0].id;
217
+ devices = await this.client.getDevices(residenceId, token, debugLog);
218
+ }
219
+ }
220
+ // Setup WebSocket for real-time updates
221
+ this.log.info('Connecting to real-time updates...');
222
+ try {
223
+ this.webSocket = (0, websocket_1.createWebSocket)(token, devices, this.handleWebSocketUpdate.bind(this), {
224
+ debug: (msg) => this.log.debug(msg),
225
+ info: (msg) => this.log.info(msg),
226
+ warn: (msg) => this.log.warn(msg),
227
+ error: (msg) => this.log.error(msg),
228
+ });
229
+ }
230
+ catch (err) {
231
+ this.log.warn(`Real-time updates unavailable: ${(0, sanitizers_1.sanitizeError)(err)}`);
232
+ }
233
+ return { devices, token, residenceId };
234
+ }
235
+ /**
236
+ * Handles WebSocket update messages
237
+ */
238
+ handleWebSocketUpdate(payload) {
239
+ if (!payload?.id) {
240
+ this.log.warn('Received invalid WebSocket payload');
241
+ return;
242
+ }
243
+ const accessory = this.accessories.find(acc => acc.context?.device?.id === payload.id);
244
+ if (!accessory) {
245
+ this.log.debug(`No accessory found for device ID: ${payload.id}`);
246
+ return;
247
+ }
248
+ const { id, power, brightness, occupancy, motion } = payload;
249
+ this.log.debug(`WebSocket: ${accessory.displayName} (${id}): ${power} ${brightness ? `${brightness}%` : ''}`);
250
+ // Get service
251
+ const fanService = accessory.getService(hap.Service.Fan);
252
+ const lightService = accessory.getService(hap.Service.Lightbulb);
253
+ const switchService = accessory.getService(hap.Service.Switch);
254
+ const outletService = accessory.getService(hap.Service.Outlet);
255
+ const primaryService = fanService || lightService || switchService || outletService;
256
+ if (!primaryService) {
257
+ this.log.warn(`No service found for accessory: ${accessory.displayName}`);
258
+ return;
259
+ }
260
+ // Update brightness/rotation speed
261
+ if (brightness !== undefined) {
262
+ const clampedBrightness = Math.max(1, brightness);
263
+ if (fanService) {
264
+ fanService.getCharacteristic(hap.Characteristic.RotationSpeed).updateValue(clampedBrightness);
265
+ }
266
+ else if (lightService) {
267
+ lightService.getCharacteristic(hap.Characteristic.Brightness).updateValue(clampedBrightness);
268
+ }
269
+ }
270
+ // Update power state
271
+ if (power !== undefined) {
272
+ primaryService.getCharacteristic(hap.Characteristic.On).updateValue(power === POWER_ON);
273
+ }
274
+ // Update motion sensor
275
+ const motionService = accessory.getService(hap.Service.MotionSensor);
276
+ if (motionService && (occupancy !== undefined || motion !== undefined)) {
277
+ const motionDetected = occupancy === true || motion === true;
278
+ motionService.getCharacteristic(hap.Characteristic.MotionDetected).updateValue(motionDetected);
279
+ }
280
+ }
281
+ /**
282
+ * Checks if device should be excluded
283
+ */
284
+ isDeviceExcluded(device, excludedModels, excludedSerials) {
285
+ if (!device?.model || !device?.serial) {
286
+ return false;
287
+ }
288
+ return excludedModels.includes(device.model.toUpperCase()) ||
289
+ excludedSerials.includes(device.serial.toUpperCase());
290
+ }
291
+ /**
292
+ * Checks if accessory already exists
293
+ */
294
+ accessoryExists(device) {
295
+ return this.accessories.some(acc => acc.context?.device?.serial === device.serial);
296
+ }
297
+ /**
298
+ * Adds a new accessory
299
+ */
300
+ async addAccessory(device, token) {
301
+ if (!device?.serial || !device?.name) {
302
+ this.log.error('Invalid device object provided to addAccessory');
303
+ return;
304
+ }
305
+ this.log.info(`Adding device: ${device.name} (${device.model})`);
306
+ const uuid = hap.uuid.generate(UUID_PREFIX + device.serial);
307
+ const accessory = new this.api.platformAccessory(device.name, uuid);
308
+ accessory.context = { device, token };
309
+ // Set device info
310
+ const infoService = accessory.getService(hap.Service.AccessoryInformation);
311
+ if (infoService) {
312
+ infoService
313
+ .setCharacteristic(hap.Characteristic.Name, device.name || 'Unknown Device')
314
+ .setCharacteristic(hap.Characteristic.SerialNumber, device.serial || 'Unknown')
315
+ .setCharacteristic(hap.Characteristic.Manufacturer, device.manufacturer || 'Leviton')
316
+ .setCharacteristic(hap.Characteristic.Model, device.model || 'Unknown')
317
+ .setCharacteristic(hap.Characteristic.FirmwareRevision, device.version || 'Unknown');
318
+ }
319
+ // Setup service
320
+ await this.setupService(accessory);
321
+ // Register with Homebridge
322
+ this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]);
323
+ this.accessories.push(accessory);
324
+ }
325
+ /**
326
+ * Configures a cached accessory
327
+ */
328
+ async configureAccessory(accessory) {
329
+ this.log.debug(`Configuring cached accessory: ${accessory.displayName}`);
330
+ await this.setupService(accessory);
331
+ this.accessories.push(accessory);
332
+ }
333
+ /**
334
+ * Sets up the appropriate service for a device
335
+ */
336
+ async setupService(accessory) {
337
+ const device = accessory.context?.device;
338
+ const token = accessory.context?.token;
339
+ if (!device || !token) {
340
+ this.log.error(`Missing device or token in accessory context: ${accessory.displayName}`);
341
+ return;
342
+ }
343
+ const model = device.model || '';
344
+ // Button controllers don't have controllable state - skip them
345
+ if (CONTROLLER_MODELS.includes(model)) {
346
+ this.log.debug(`Skipping controller device: ${device.name} (${model})`);
347
+ return;
348
+ }
349
+ if (model === FAN_MODEL) {
350
+ await this.setupFanService(accessory, device, token);
351
+ }
352
+ else if (MOTION_DIMMER_MODELS.includes(model)) {
353
+ await this.setupMotionDimmerService(accessory, device, token);
354
+ }
355
+ else if (DIMMER_MODELS.includes(model)) {
356
+ await this.setupLightbulbService(accessory, device, token);
357
+ }
358
+ else if (OUTLET_MODELS.includes(model)) {
359
+ await this.setupBasicService(accessory, device, token, hap.Service.Outlet);
360
+ }
361
+ else if (SWITCH_MODELS.includes(model)) {
362
+ await this.setupBasicService(accessory, device, token, hap.Service.Switch);
363
+ }
364
+ else {
365
+ // Unknown model - treat as switch
366
+ this.log.info(`Unknown device model '${model}' for ${device.name}, treating as switch`);
367
+ await this.setupBasicService(accessory, device, token, hap.Service.Switch);
368
+ }
369
+ }
370
+ /**
371
+ * Gets device status with error handling
372
+ */
373
+ async getStatus(device, token) {
374
+ try {
375
+ return await this.client.getDeviceStatus(device.id, token);
376
+ }
377
+ catch (err) {
378
+ this.log.error(`Failed to get status for ${device.name}: ${(0, sanitizers_1.sanitizeError)(err)}`);
379
+ return { power: POWER_OFF, brightness: 0, minLevel: 1, maxLevel: 100 };
380
+ }
381
+ }
382
+ /**
383
+ * Sets up a lightbulb service
384
+ */
385
+ async setupLightbulbService(accessory, device, token) {
386
+ const status = await this.getStatus(device, token);
387
+ const service = accessory.getService(hap.Service.Lightbulb, device.name) ||
388
+ accessory.addService(hap.Service.Lightbulb, device.name);
389
+ // Calculate valid brightness range
390
+ const minBrightness = status.minLevel || 1;
391
+ const maxBrightness = status.maxLevel || 100;
392
+ // Ensure brightness is within valid range (0 is invalid for HomeKit Brightness which has minValue=1)
393
+ const rawBrightness = typeof status.brightness === 'number' ? status.brightness : 0;
394
+ const safeBrightness = rawBrightness < minBrightness ? minBrightness : rawBrightness;
395
+ // Setup On characteristic
396
+ const onChar = service.getCharacteristic(hap.Characteristic.On);
397
+ onChar.removeAllListeners('get');
398
+ onChar.removeAllListeners('set');
399
+ onChar.on('get', this.createPowerGetter(device));
400
+ onChar.on('set', this.createPowerSetter(device));
401
+ onChar.updateValue(status.power === POWER_ON);
402
+ // Setup Brightness characteristic
403
+ // Use getCharacteristic which always returns a valid Characteristic object
404
+ const brightnessChar = service.getCharacteristic(hap.Characteristic.Brightness);
405
+ // Set props first to establish valid range, then update value
406
+ brightnessChar.setProps({ minValue: minBrightness, maxValue: maxBrightness, minStep: 1 });
407
+ brightnessChar.removeAllListeners('get');
408
+ brightnessChar.removeAllListeners('set');
409
+ brightnessChar.on('get', this.createBrightnessGetter(device));
410
+ brightnessChar.on('set', this.createBrightnessSetter(device));
411
+ brightnessChar.updateValue(safeBrightness);
412
+ }
413
+ /**
414
+ * Sets up a motion dimmer service
415
+ */
416
+ async setupMotionDimmerService(accessory, device, token) {
417
+ await this.setupLightbulbService(accessory, device, token);
418
+ const status = await this.getStatus(device, token);
419
+ const motionService = accessory.getService(hap.Service.MotionSensor) ||
420
+ accessory.addService(hap.Service.MotionSensor, `${device.name} Motion`);
421
+ motionService
422
+ .getCharacteristic(hap.Characteristic.MotionDetected)
423
+ .updateValue(status.occupancy === true || status.motion === true);
424
+ }
425
+ /**
426
+ * Sets up a fan service
427
+ */
428
+ async setupFanService(accessory, device, token) {
429
+ const status = await this.getStatus(device, token);
430
+ const service = accessory.getService(hap.Service.Fan, device.name) ||
431
+ accessory.addService(hap.Service.Fan, device.name);
432
+ // Setup On characteristic
433
+ const onChar = service.getCharacteristic(hap.Characteristic.On);
434
+ onChar.removeAllListeners('get');
435
+ onChar.removeAllListeners('set');
436
+ onChar.on('get', this.createPowerGetter(device));
437
+ onChar.on('set', this.createPowerSetter(device));
438
+ onChar.updateValue(status.power === POWER_ON);
439
+ // Setup RotationSpeed characteristic - set props before value
440
+ const speedChar = service.getCharacteristic(hap.Characteristic.RotationSpeed);
441
+ speedChar.setProps({ minValue: 0, maxValue: status.maxLevel || 100, minStep: status.minLevel || 1 });
442
+ speedChar.removeAllListeners('get');
443
+ speedChar.removeAllListeners('set');
444
+ speedChar.on('get', this.createBrightnessGetter(device));
445
+ speedChar.on('set', this.createBrightnessSetter(device));
446
+ speedChar.updateValue(status.brightness || 0);
447
+ }
448
+ /**
449
+ * Sets up a basic switch/outlet service
450
+ */
451
+ async setupBasicService(accessory, device, token, ServiceType) {
452
+ const status = await this.getStatus(device, token);
453
+ const service = accessory.getService(ServiceType, device.name) ||
454
+ accessory.addService(ServiceType, device.name);
455
+ service
456
+ .getCharacteristic(hap.Characteristic.On)
457
+ .on('get', this.createPowerGetter(device))
458
+ .on('set', this.createPowerSetter(device))
459
+ .updateValue(status.power === POWER_ON);
460
+ }
461
+ /**
462
+ * Creates a power getter handler
463
+ */
464
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
465
+ createPowerGetter(device) {
466
+ return async (callback) => {
467
+ try {
468
+ const token = await this.ensureValidToken();
469
+ const status = await this.client.getDeviceStatus(device.id, token);
470
+ callback(null, status.power === POWER_ON);
471
+ }
472
+ catch (err) {
473
+ callback(new Error((0, sanitizers_1.sanitizeError)(err)));
474
+ }
475
+ };
476
+ }
477
+ /**
478
+ * Creates a power setter handler
479
+ */
480
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
481
+ createPowerSetter(device) {
482
+ return async (value, callback) => {
483
+ try {
484
+ const token = await this.ensureValidToken();
485
+ await this.client.setPower(device.id, token, value);
486
+ this.log.info(`${device.name}: ${value ? 'ON' : 'OFF'}`);
487
+ callback();
488
+ }
489
+ catch (err) {
490
+ callback(new Error((0, sanitizers_1.sanitizeError)(err)));
491
+ }
492
+ };
493
+ }
494
+ /**
495
+ * Creates a brightness getter handler
496
+ */
497
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
498
+ createBrightnessGetter(device) {
499
+ return async (callback) => {
500
+ try {
501
+ const token = await this.ensureValidToken();
502
+ const status = await this.client.getDeviceStatus(device.id, token);
503
+ callback(null, Math.max(1, status.brightness || 0));
504
+ }
505
+ catch (err) {
506
+ callback(new Error((0, sanitizers_1.sanitizeError)(err)));
507
+ }
508
+ };
509
+ }
510
+ /**
511
+ * Creates a brightness setter handler
512
+ */
513
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
514
+ createBrightnessSetter(device) {
515
+ return async (value, callback) => {
516
+ try {
517
+ const token = await this.ensureValidToken();
518
+ await this.client.setBrightness(device.id, token, value);
519
+ this.log.info(`${device.name}: ${value}%`);
520
+ callback();
521
+ }
522
+ catch (err) {
523
+ callback(new Error((0, sanitizers_1.sanitizeError)(err)));
524
+ }
525
+ };
526
+ }
527
+ /**
528
+ * Ensures a valid token is available
529
+ */
530
+ async ensureValidToken() {
531
+ if (this.currentToken) {
532
+ return this.currentToken;
533
+ }
534
+ return this.refreshToken();
535
+ }
536
+ /**
537
+ * Refreshes the authentication token
538
+ */
539
+ async refreshToken() {
540
+ if (this.tokenRefreshInProgress) {
541
+ await new Promise(resolve => setTimeout(resolve, 2000));
542
+ return this.currentToken;
543
+ }
544
+ this.tokenRefreshInProgress = true;
545
+ try {
546
+ const login = await this.client.login(this.config.email, this.config.password);
547
+ this.currentToken = login.id;
548
+ // Update token in all accessory contexts
549
+ this.accessories.forEach(acc => {
550
+ if (acc.context) {
551
+ acc.context.token = this.currentToken;
552
+ }
553
+ });
554
+ this.log.info('Token refreshed successfully');
555
+ return this.currentToken;
556
+ }
557
+ finally {
558
+ this.tokenRefreshInProgress = false;
559
+ }
560
+ }
561
+ /**
562
+ * Starts polling for device updates
563
+ */
564
+ startPolling() {
565
+ const interval = Math.max((this.config.pollingInterval || 30) * 1000, 10000);
566
+ this.log.info(`Starting device polling (every ${interval / 1000}s)`);
567
+ this.pollingInterval = setInterval(() => this.pollDevices(), interval);
568
+ }
569
+ /**
570
+ * Polls all devices for updates
571
+ */
572
+ async pollDevices() {
573
+ if (!this.residenceId || !this.currentToken) {
574
+ return;
575
+ }
576
+ try {
577
+ const devices = await this.client.getDevices(this.residenceId, this.currentToken);
578
+ for (const device of devices) {
579
+ if (device?.id) {
580
+ this.handleWebSocketUpdate({
581
+ id: device.id,
582
+ power: device.power,
583
+ brightness: device.brightness,
584
+ });
585
+ }
586
+ }
587
+ }
588
+ catch (err) {
589
+ this.log.debug(`Polling error: ${(0, sanitizers_1.sanitizeError)(err)}`);
590
+ }
591
+ }
592
+ /**
593
+ * Saves device states to persistence
594
+ */
595
+ saveDeviceStates() {
596
+ try {
597
+ this.accessories.forEach(accessory => {
598
+ const device = accessory.context?.device;
599
+ if (device) {
600
+ const service = accessory.getService(hap.Service.Lightbulb) ||
601
+ accessory.getService(hap.Service.Fan) ||
602
+ accessory.getService(hap.Service.Switch) ||
603
+ accessory.getService(hap.Service.Outlet);
604
+ if (service) {
605
+ const isOn = service.getCharacteristic(hap.Characteristic.On).value;
606
+ this.devicePersistence.updateDevice(device.id, {
607
+ id: device.id,
608
+ name: device.name,
609
+ model: device.model,
610
+ power: isOn ? POWER_ON : POWER_OFF,
611
+ });
612
+ }
613
+ }
614
+ });
615
+ this.devicePersistence.save();
616
+ }
617
+ catch (err) {
618
+ this.log.error(`Failed to save device states: ${(0, sanitizers_1.sanitizeError)(err)}`);
619
+ }
620
+ }
621
+ /**
622
+ * Starts periodic cleanup
623
+ */
624
+ startPeriodicCleanup() {
625
+ this.cleanupInterval = setInterval(() => {
626
+ this.client.clearCache();
627
+ }, 60000);
628
+ }
629
+ /**
630
+ * Cleans up resources
631
+ */
632
+ cleanup() {
633
+ if (this.pollingInterval) {
634
+ clearInterval(this.pollingInterval);
635
+ this.pollingInterval = null;
636
+ }
637
+ if (this.cleanupInterval) {
638
+ clearInterval(this.cleanupInterval);
639
+ this.cleanupInterval = null;
640
+ }
641
+ if (this.webSocket) {
642
+ this.webSocket.close();
643
+ this.webSocket = null;
644
+ }
645
+ }
646
+ /**
647
+ * Removes all accessories
648
+ */
649
+ removeAccessories() {
650
+ this.log.info('Removing all accessories');
651
+ this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, this.accessories);
652
+ this.accessories.length = 0;
653
+ }
654
+ }
655
+ exports.LevitonDecoraSmartPlatform = LevitonDecoraSmartPlatform;
656
+ /**
657
+ * Homebridge plugin registration
658
+ */
659
+ function registerPlatform(homebridge) {
660
+ hap = homebridge.hap;
661
+ homebridge.registerPlatform(PLUGIN_NAME, PLATFORM_NAME, LevitonDecoraSmartPlatform, true);
662
+ }
663
+ exports.default = registerPlatform;
664
+ //# sourceMappingURL=platform.js.map