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.
- package/LICENSE +202 -0
- package/README.md +112 -0
- package/config.schema.json +136 -0
- package/dist/api/cache.d.ts +108 -0
- package/dist/api/cache.d.ts.map +1 -0
- package/dist/api/cache.js +206 -0
- package/dist/api/cache.js.map +1 -0
- package/dist/api/circuit-breaker.d.ts +118 -0
- package/dist/api/circuit-breaker.d.ts.map +1 -0
- package/dist/api/circuit-breaker.js +223 -0
- package/dist/api/circuit-breaker.js.map +1 -0
- package/dist/api/client.d.ts +116 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.js +358 -0
- package/dist/api/client.js.map +1 -0
- package/dist/api/index.d.ts +23 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/index.js +47 -0
- package/dist/api/index.js.map +1 -0
- package/dist/api/persistence.d.ts +107 -0
- package/dist/api/persistence.d.ts.map +1 -0
- package/dist/api/persistence.js +285 -0
- package/dist/api/persistence.js.map +1 -0
- package/dist/api/rate-limiter.d.ts +102 -0
- package/dist/api/rate-limiter.d.ts.map +1 -0
- package/dist/api/rate-limiter.js +173 -0
- package/dist/api/rate-limiter.js.map +1 -0
- package/dist/api/request-queue.d.ts +104 -0
- package/dist/api/request-queue.d.ts.map +1 -0
- package/dist/api/request-queue.js +223 -0
- package/dist/api/request-queue.js.map +1 -0
- package/dist/api/websocket.d.ts +116 -0
- package/dist/api/websocket.d.ts.map +1 -0
- package/dist/api/websocket.js +319 -0
- package/dist/api/websocket.js.map +1 -0
- package/dist/errors/index.d.ts +182 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +273 -0
- package/dist/errors/index.js.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +42 -0
- package/dist/index.js.map +1 -0
- package/dist/platform.d.ts +139 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +664 -0
- package/dist/platform.js.map +1 -0
- package/dist/types/index.d.ts +225 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +34 -0
- package/dist/types/index.js.map +1 -0
- package/dist/utils/index.d.ts +15 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +52 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/logger.d.ts +103 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +184 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/retry.d.ts +56 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +141 -0
- package/dist/utils/retry.js.map +1 -0
- package/dist/utils/sanitizers.d.ts +37 -0
- package/dist/utils/sanitizers.d.ts.map +1 -0
- package/dist/utils/sanitizers.js +128 -0
- package/dist/utils/sanitizers.js.map +1 -0
- package/dist/utils/validators.d.ts +51 -0
- package/dist/utils/validators.d.ts.map +1 -0
- package/dist/utils/validators.js +243 -0
- package/dist/utils/validators.js.map +1 -0
- package/package.json +69 -0
package/dist/platform.js
ADDED
|
@@ -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
|