UNPKG

homebridge-kasa-python

Version:

Plugin that uses Python-Kasa API to communicate with Kasa Devices.

449 lines 21.5 kB
import path from 'node:path'; import { EventEmitter } from 'node:events'; import { fileURLToPath } from 'node:url'; import create from './devices/create.js'; import DeviceManager, { deviceEventEmitter } from './devices/deviceManager.js'; import PythonChecker from './python/pythonChecker.js'; import { parseConfig } from './config.js'; import { PLATFORM_NAME, PLUGIN_NAME } from './settings.js'; import { TaskQueue } from './taskQueue.js'; import { deferAndCombine, getAvailablePort, isObjectLike, loadPackageConfig, lookup, lookupCharacteristicNameByUUID, runCommand, satisfiesVersion, waitForServer, } from './utils.js'; import { createEnergyCharacteristics } from './devices/energyCharacteristics.js'; let packageConfig; const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default class KasaPythonPlatform { log; api; Characteristic; configuredAccessories = new Map(); offlineAccessories = new Map(); Service; storagePath; config; deviceManager; energyCharacteristics; isShuttingDown = false; periodicDeviceDiscovering = false; periodicDeviceDiscoveryEmitter; port = 0; taskQueue; venvPythonExecutable = ''; homekitDevicesById = new Map(); deviceDiscoveredHandler; discoveryInterval; hideHomeKitMatter = true; kasaProcess = null; platformInitialization; constructor(log, config, api) { this.log = log; this.api = api; this.Service = this.api.hap.Service; this.Characteristic = this.api.hap.Characteristic; this.storagePath = this.api.user.storagePath(); this.config = parseConfig(config); this.energyCharacteristics = this.config.energyOptions.enableEnergyMonitoring ? createEnergyCharacteristics(this.api.hap) : undefined; this.periodicDeviceDiscoveryEmitter = new EventEmitter(); this.taskQueue = new TaskQueue(this.log, () => this.isShuttingDown); this.periodicDeviceDiscoveryEmitter.setMaxListeners(255); this.setupDeviceEventEmitter('firstDiscovery'); this.platformInitialization = this.initializePlatform(); this.api.on('didFinishLaunching', async () => { this.log.debug('KasaPython Platform finished launching'); await this.platformInitialization; await this.didFinishLaunching(); if (this.offlineAccessories.size > 0) { this.log.debug('Unregistering offline accessories'); this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, Array.from(this.offlineAccessories.values())); this.offlineAccessories.clear(); } }); this.api.on('shutdown', async () => { this.log.debug('KasaPython shutting down'); if (!this.isShuttingDown) { this.isShuttingDown = true; } if (this.discoveryInterval) { clearInterval(this.discoveryInterval); this.log.debug('Cleared periodic device discovery interval'); } this.log.debug('Stopping all polling tasks'); for (const device of this.homekitDevicesById.values()) { await device.stopPolling(true); } this.log.debug('Waiting for tasks to complete'); try { await this.taskQueue.waitForEmptyQueue(); } catch (error) { this.log.error('Error while waiting for task queue to empty during shutdown:', error); } this.stopKasaApi(); }); } setupDeviceEventEmitter(mode, discoveredDeviceIds) { if (this.deviceDiscoveredHandler) { deviceEventEmitter.off('deviceDiscovered', this.deviceDiscoveredHandler); } this.log.debug(`Setting up device event emitter: ${mode}`); if (mode === 'periodicDiscovery' && discoveredDeviceIds) { this.deviceDiscoveredHandler = async (device) => { this.log.debug(`Device discovered during periodic discovery: ${device.sys_info.device_id}`); discoveredDeviceIds.add(device.sys_info.device_id); this.log.debug(`Added device ID to discoveredDeviceIds: ${device.sys_info.device_id}`); await this.processDevice(device); }; } else { this.deviceDiscoveredHandler = async (device) => { this.log.debug(`Device discovered during initial discovery: ${device.sys_info.device_id}`); await this.processDevice(device); }; } deviceEventEmitter.on('deviceDiscovered', this.deviceDiscoveredHandler); } async initializePlatform() { packageConfig = await loadPackageConfig(this.log); this.logInitializationDetails(); await this.verifyEnvironment(); } logInitializationDetails() { this.log.info(`${packageConfig.name} v${packageConfig.version}, node ${process.version}, ` + `homebridge v${this.api.serverVersion}, api v${this.api.version} Initializing...`); } async verifyEnvironment() { this.log.debug('Verifying environment'); try { this.log.debug('Checking Node.js version'); if (!satisfiesVersion(process.version, packageConfig.engines.node)) { this.log.error(`Error: not using minimum node version ${packageConfig.engines.node}`); } else { this.log.debug(`Node.js version ${process.version} satisfies the requirement ${packageConfig.engines.node}`); } this.log.debug('Checking Homebridge version'); if (this.api.versionGreaterOrEqual && !(this.api.versionGreaterOrEqual('1.8.0') || this.api.versionGreaterOrEqual('2.0.0'))) { throw new Error(`homebridge-kasa-python requires Homebridge ^1.8.0 || ^2.0.0-beta.0. Currently running: ${this.api.serverVersion}`); } else { this.log.debug(`Homebridge version ${this.api.serverVersion} satisfies the requirement ^1.8.0 || ^2.0.0-beta.0`); } } catch (error) { this.log.error('Error verifying environment:', error); throw error; } } async didFinishLaunching() { this.log.debug('Finished launching'); try { this.log.debug('Checking Python environment'); await this.checkPython(); this.log.debug('Getting available port'); this.port = await getAvailablePort(); this.log.debug(`Port assigned: ${this.port}`); this.log.debug('Initializing DeviceManager'); this.deviceManager = new DeviceManager(this); this.log.debug('DeviceManager initialized'); await this.startKasaApi(); await waitForServer(`http://127.0.0.1:${this.port}/health`, this.log); await this.discoverDevices(); this.log.debug('Device discovery completed'); const discoveredDeviceIds = new Set(); this.setupPeriodicDiscovery(discoveredDeviceIds); } catch (error) { this.log.error('An error occurred during startup:', error); } } setupPeriodicDiscovery(discoveredDeviceIds) { this.log.debug('Setting up periodic device discovery'); this.setupDeviceEventEmitter('periodicDiscovery', discoveredDeviceIds); const discoveryTask = async () => { await this.periodicDeviceDiscovery(discoveredDeviceIds); }; const deferredDiscoveryTask = deferAndCombine(discoveryTask, this.config.advancedOptions.waitTimeUpdate); this.discoveryInterval = setInterval(() => { try { this.taskQueue.addTask(deferredDiscoveryTask); } catch (err) { this.log.error('Error scheduling periodic device discovery:', err); } }, this.config.discoveryOptions.discoveryPollingInterval); this.log.debug('Periodic device discovery setup completed'); } async discoverDevices() { try { if (this.deviceManager) { await this.deviceManager.discoverDevices(); } } catch (error) { this.log.error('Error during discoverDevices:', error); } } async periodicDeviceDiscovery(discoveredDeviceIds) { this.log.debug('Starting periodic device discovery'); if (this.periodicDeviceDiscovering) { this.log.debug('Periodic device discovery already in progress'); return; } if (this.isShuttingDown) { this.log.debug('Platform is shutting down, skipping periodic device discovery'); return; } this.periodicDeviceDiscovering = true; discoveredDeviceIds.clear(); this.log.debug('Cleared discoveredDeviceIds set before discovery.'); try { if (this.deviceManager) { await this.deviceManager.discoverDevices(); } } catch (error) { this.log.error('Error during periodic device discovery:', error); } finally { this.handleOfflineDevices(discoveredDeviceIds); this.periodicDeviceDiscovering = false; this.periodicDeviceDiscoveryEmitter.emit('periodicDeviceDiscoveryComplete'); this.log.debug('Finished periodic device discovery'); } } async processDevice(device) { this.log.debug(`Processing device: ${device.sys_info.device_id}`); try { const now = new Date(); device.last_seen = now; device.offline = false; const accessory = this.findPlatformAccessory(device.sys_info.device_id); if (accessory) { await this.updateExistingDevice(accessory, device, now); } else { await this.addNewDevice(device); } } catch (error) { this.log.error(`Error processing device [${device.sys_info.device_id}]:`, error); } } handleOfflineDevices(discoveredDeviceIds) { const now = new Date(); this.configuredAccessories.forEach((accessory, uuid) => { const deviceId = accessory.context.deviceId; if (!deviceId) { this.log.warn(`Accessory [${accessory.displayName}] is missing a deviceId.`); return; } if (discoveredDeviceIds.has(deviceId)) { this.log.debug(`Accessory [${accessory.displayName}] was discovered and is online.`); this.updateAccessoryStatus(accessory, now, false); } else { this.handleOfflineAccessory(accessory, uuid, now); } }); } handleOfflineAccessory(accessory, uuid, now) { const timeSinceLastSeen = now.getTime() - new Date(accessory.context.lastSeen || 0).getTime(); const offlineInterval = this.config.discoveryOptions.offlineInterval; if (timeSinceLastSeen > offlineInterval) { this.log.debug(`Accessory [${accessory.displayName}] is offline and outside the offline interval. Removing.`); this.api.unregisterPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); this.configuredAccessories.delete(uuid); } else if (!accessory.context.offline) { this.log.debug(`Accessory [${accessory.displayName}] is offline but within the offline interval.`); this.updateAccessoryStatus(accessory, accessory.context.lastSeen || now, true); } } findPlatformAccessory(deviceId) { for (const accessory of this.configuredAccessories.values()) { if (accessory.context.deviceId === deviceId) { return accessory; } } return undefined; } async updateExistingDevice(accessory, device, now) { this.log.debug(`Device [${device.sys_info.device_id}] is already configured, updating status.`); this.updateAccessoryStatus(accessory, now, false); const existingDevice = this.homekitDevicesById.get(device.sys_info.device_id); if (existingDevice) { if (!existingDevice.isUpdating) { const comingOnline = existingDevice.kasaDevice.offline && !device.offline; existingDevice.kasaDevice.sys_info = device.sys_info; existingDevice.kasaDevice.feature_info = device.feature_info; existingDevice.kasaDevice.last_seen = device.last_seen; existingDevice.kasaDevice.offline = device.offline; if (comingOnline) { this.log.debug(`Device [${device.sys_info.device_id}] was offline and is now online. Updating and restarting polling.`); existingDevice.updateAfterPeriodicDiscovery(true); existingDevice.startPolling(); } else { this.log.debug(`Updating existing HomeKit device [${device.sys_info.device_id}].`); existingDevice.updateAfterPeriodicDiscovery(); } } else { this.log.debug(`HomeKit device [${device.sys_info.device_id}] is currently updating. Skipping update.`); } } else { await this.addNewDevice(device); } } async addNewDevice(device) { this.log.debug(`New device [${device.sys_info.device_id}] found, adding to HomeKit.`); await this.foundDevice(device); } updateAccessoryStatus(accessory, lastSeen, offline) { const offlineStatusChanged = accessory.context.offline !== offline; accessory.context.lastSeen = lastSeen; accessory.context.offline = offline; if (offlineStatusChanged) { this.api.updatePlatformAccessories([accessory]); } } async checkPython() { try { this.log.debug('Running PythonChecker'); await new PythonChecker(this).allInOne(); } catch (error) { this.log.error('Error checking python environment:', error); throw error; } } async startKasaApi() { const scriptPath = path.join(__dirname, 'python', 'startKasaApi.py'); this.hideHomeKitMatter = this.config.homekitOptions.hideHomeKitMatter; this.log.debug('Starting Kasa API with script:', scriptPath); try { const [, , , kasaProcessInstance] = await runCommand(this.log, this.venvPythonExecutable, [scriptPath, this.port.toString(), this.hideHomeKitMatter.toString()], undefined, this.config.advancedOptions.advancedPythonLogging ? false : true, this.config.advancedOptions.advancedPythonLogging ? false : true, true); this.kasaProcess = kasaProcessInstance; this.log.debug('Kasa API process started successfully'); } catch (error) { this.log.error(`Error starting kasaApi.py process: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } stopKasaApi() { this.log.debug('Stopping Kasa API'); if (this.kasaProcess) { this.log.debug('Kasa API process found, attempting to kill the process'); this.kasaProcess.kill(); this.kasaProcess = null; this.log.debug('Kasa API process successfully killed'); } else { this.log.debug('No Kasa API process found to stop'); } } lsc(serviceOrCharacteristic, characteristic) { const serviceName = serviceOrCharacteristic instanceof this.api.hap.Service ? this.getServiceName(serviceOrCharacteristic) : undefined; const characteristicName = characteristic instanceof this.api.hap.Characteristic ? this.getCharacteristicName(characteristic) : serviceOrCharacteristic instanceof this.api.hap.Characteristic || 'UUID' in serviceOrCharacteristic ? this.getCharacteristicName(serviceOrCharacteristic) : undefined; const result = `[${serviceName ? serviceName : ''}` + `${serviceName && characteristicName ? '.' : ''}` + `${characteristicName ? characteristicName : ''}]`; return result; } getServiceName(service) { const serviceName = lookup(this.api.hap.Service, (objectProp, value) => isObjectLike(objectProp) && 'UUID' in objectProp && objectProp.UUID === value, service.UUID); return serviceName; } getCharacteristicName(characteristic) { const name = characteristic.name; const displayName = characteristic.displayName; const lookupName = lookupCharacteristicNameByUUID(this.api.hap.Characteristic, characteristic.UUID); return name ?? displayName ?? lookupName; } registerPlatformAccessory(accessory) { this.log.debug('Registering platform accessory:', accessory.displayName); if (!this.configuredAccessories.has(accessory.UUID)) { this.log.debug(`Platform Accessory ${accessory.displayName} is not in configuredAccessories, adding it.`); this.configuredAccessories.set(accessory.UUID, accessory); this.api.registerPlatformAccessories(PLUGIN_NAME, PLATFORM_NAME, [accessory]); this.log.debug(`Platform Accessory ${accessory.displayName} registered with Homebridge.`); } else { this.log.debug(`Platform Accessory ${accessory.displayName} is already in configuredAccessories, skipping registration.`); } } configureAccessory(accessory) { this.log.debug(`Configuring Platform Accessory: [${accessory.displayName}] UUID: ${accessory.UUID}`); if (!accessory.context.lastSeen && !accessory.context.offline) { this.log.debug(`Setting initial lastSeen and offline status for Platform Accessory: [${accessory.displayName}]`); accessory.context.lastSeen = new Date(); accessory.context.offline = false; } if (accessory.context.lastSeen) { const now = new Date(); const timeSinceLastSeen = now.getTime() - new Date(accessory.context.lastSeen).getTime(); const offlineInterval = this.config.discoveryOptions.offlineInterval; this.log.debug(`Platform Accessory [${accessory.displayName}] last seen ${timeSinceLastSeen}ms ago, ` + `offline interval is ${offlineInterval}ms, offline status: ${accessory.context.offline}`); if (timeSinceLastSeen > offlineInterval && accessory.context.offline === true) { this.log.debug(`Platform Accessory [${accessory.displayName}] is offline and outside the offline interval, ` + 'moving to offlineAccessories'); this.configuredAccessories.delete(accessory.UUID); this.offlineAccessories.set(accessory.UUID, accessory); return; } else if (timeSinceLastSeen < offlineInterval && accessory.context.offline === true) { this.log.debug(`Platform Accessory [${accessory.displayName}] is offline and within offline interval.`); } else if (accessory.context.offline === false) { this.log.debug(`Platform Accessory [${accessory.displayName}] is online, updating lastSeen time.`); this.updateAccessoryStatus(accessory, now, false); } } if (!this.configuredAccessories.has(accessory.UUID)) { this.log.debug(`Platform Accessory [${accessory.displayName}] with UUID [${accessory.UUID}] ` + 'is not in configuredAccessories, adding it.'); this.configuredAccessories.set(accessory.UUID, accessory); } else { this.log.debug(`Platform Accessory [${accessory.displayName}] with UUID ` + `[${accessory.UUID}] is already in configuredAccessories.`); } } async foundDevice(device) { const { sys_info: { alias: deviceAlias, device_id: deviceId, device_type: deviceType, host: deviceHost } } = device; if (!deviceId) { this.log.error('Missing deviceId:', deviceHost); return; } if (this.homekitDevicesById.has(deviceId)) { this.log.debug(`HomeKit device already added: [${deviceAlias}] ${deviceType} [${deviceId}]`); return; } this.log.info(`Adding HomeKit device: [${deviceAlias}] ${deviceType} [${deviceId}] at host [${deviceHost}]`); const homekitDevice = await this.createHomeKitDevice(device); if (homekitDevice) { this.homekitDevicesById.set(deviceId, homekitDevice); this.log.debug(`HomeKit device [${deviceAlias}] ${deviceType} [${deviceId}] successfully added`); } else { this.log.error(`Failed to add HomeKit device for: [${deviceAlias}] ${deviceType} [${deviceId}]`); } } async createHomeKitDevice(kasaDevice) { this.log.debug('Creating HomeKit device for:', kasaDevice.sys_info); return await create(this, kasaDevice); } } //# sourceMappingURL=platform.js.map