UNPKG

homebridge-adt-pulse

Version:
630 lines 31.8 kB
import { arch, argv, platform, versions, } from 'node:process'; import chalk from 'chalk'; import _ from 'lodash'; import { serializeError } from 'serialize-error'; import { ADTPulseAccessory } from './accessory.js'; import { ADTPulseAPI } from './api.js'; import { detectPlatformUnknownSensorsAction } from './detect.js'; import { textOrbTextSummarySections } from './regex.js'; import { platformConfig } from './schema.js'; import { condenseSensorType, findIndexWithValue, generateHash, getAccessoryCategory, getPackageVersion, getPluralForm, isMaintenancePeriod, sleep, stackTracer, } from './utility.js'; export class ADTPulsePlatform { #accessories; #api; #characteristic; #config; #constants; #debugMode; #handlers; #instance; #log; #service; #state; constructor(log, config, api) { this.#accessories = []; this.#api = api; this.#characteristic = api.hap.Characteristic; this.#config = null; this.#constants = { intervalTimestamps: { adtKeepAlive: 538000, adtSessionLifespan: 19368000, adtSyncCheck: 3000, suspendSyncing: 1800000, synchronize: 1000, }, maxLoginRetries: 3, }; this.#debugMode = argv.includes('-D') || argv.includes('--debug'); this.#handlers = {}; this.#instance = null; this.#log = log; this.#service = api.hap.Service; this.#state = { activity: { isAdtKeepingAlive: false, isAdtSyncChecking: false, isLoggingIn: false, isSyncing: false, }, data: { gatewayInfo: null, orbSecurityButtons: [], panelInfo: null, panelStatus: null, sensorsInfo: [], sensorsStatus: [], syncCode: '1-0-0', }, eventCounters: { failedLogins: 0, }, intervals: { synchronize: undefined, }, lastRunOn: { adtKeepAlive: 0, adtLastLogin: 0, adtSyncCheck: 0, }, reportedHashes: [], }; const parsedConfig = platformConfig.safeParse(config); if (!parsedConfig.success) { this.#log.error('Plugin is unable to initialize due to an invalid platform configuration.'); this.#log.warn('If you just upgraded from v2 to v3, the configuration has been changed. Please re-configure your plugin.'); this.#log.warn('Carefully observe the error below. The answer you are looking for is there.'); stackTracer('zod-error', parsedConfig.error.errors); return; } api.on('didFinishLaunching', async () => { this.#config = parsedConfig.data; this.printSystemInformation(); this.#log.info('The API gathers anonymous analytics to detect potential bugs or issues. All personally identifiable information will be redacted.'); if (this.#config.mode === 'paused') { this.#log.warn('Plugin is now paused and all related accessories will no longer respond.'); return; } if (this.#config.mode === 'reset') { let warningCount = 0; let warningTerm = ''; this.#log.warn('Plugin started in reset mode and will remove all accessories shortly.'); await sleep(10000); for (let i = 3; i >= 1; i -= 1) { const seconds = 10 * i; warningCount += 1; switch (warningCount) { case 1: warningTerm = 'FIRST'; break; case 2: warningTerm = 'SECOND'; break; case 3: default: warningTerm = 'FINAL'; break; } this.#log.warn(`${warningTerm} WARNING! ${seconds} SECONDS REMAINING before all related accessories are removed.`); await sleep(10000); } this.#log.warn('Plugin is now removing all related accessories from Homebridge ...'); for (let i = this.#accessories.length - 1; i >= 0; i -= 1) { this.removeAccessory(this.#accessories[i], 'plugin is in "reset" mode'); } this.#log.info('Plugin finished removing all related accessories from Homebridge.'); return; } if (this.#config.speed !== 1) { this.#log.warn(`Plugin is now running under ${this.#config.speed}x operational speed. You may see slower device updates.`); this.#constants.intervalTimestamps.synchronize *= (1 / this.#config.speed); } if (this.#config.options.length > 0) { this.#log.warn('Plugin will apply the advanced options saved in the configuration. You may see some loss in functionality.'); stackTracer('config-content', this.#config.options); } this.#instance = new ADTPulseAPI(this.#config, { debug: this.#debugMode === true, logger: this.#log, }); this.synchronize(); }); } configureAccessory(accessory) { this.#log.info(`Configuring cached accessory for ${chalk.underline(accessory.context.name)} (id: ${accessory.context.id}, uuid: ${accessory.context.uuid}) ...`); this.#accessories.push(accessory); } addAccessory(device) { const accessoryIndex = this.#accessories.findIndex((accessory) => device.uuid === accessory.context.uuid); if (accessoryIndex >= 0) { this.#log.error(`Cannot add ${chalk.underline(device.name)} (id: ${device.id}, uuid: ${device.uuid}) accessory that already exists.`); return; } if (this.#instance === null) { this.#log.error(`Attempted to add ${chalk.underline(device.name)} (id: ${device.id}, uuid: ${device.uuid}) accessory but API instance is not available.`); return; } const newAccessory = new this.#api.platformAccessory(device.name, device.uuid, getAccessoryCategory(device.category)); newAccessory.context = device; const typedAccessory = newAccessory; this.#log.info(`Adding ${chalk.underline(typedAccessory.context.name)} (id: ${typedAccessory.context.id}, uuid: ${typedAccessory.context.uuid}) accessory ...`); if (this.#handlers[device.id] === undefined) { this.#handlers[device.id] = new ADTPulseAccessory(typedAccessory, this.#state, this.#config, this.#instance, this.#service, this.#characteristic, this.#api, this.#log); } this.#handlers[device.id].updater(); this.#accessories.push(typedAccessory); this.#api.registerPlatformAccessories('homebridge-adt-pulse', 'ADTPulse', [typedAccessory]); } updateAccessory(device) { const { index, value } = findIndexWithValue(this.#accessories, (accessory) => device.uuid === accessory.context.uuid); if (index < 0 || value === undefined) { this.#log.warn(`Attempted to update ${chalk.underline(device.name)} (id: ${device.id}, uuid: ${device.uuid}) accessory that does not exist.`); return; } if (this.#instance === null) { this.#log.error(`Attempted to updated ${chalk.underline(device.name)} (id: ${device.id}, uuid: ${device.uuid}) accessory but API instance is not available.`); return; } this.#log.debug(`Updating ${chalk.underline(value.context.name)} (id: ${value.context.id}, uuid: ${value.context.uuid}) accessory ...`); value.context = device; value.displayName = device.name; if (this.#handlers[device.id] === undefined) { this.#handlers[device.id] = new ADTPulseAccessory(value, this.#state, this.#config, this.#instance, this.#service, this.#characteristic, this.#api, this.#log); } this.#handlers[device.id].updater(); this.#accessories[index] = value; this.#api.updatePlatformAccessories([value]); } removeAccessory(accessory, reason) { this.#log.info(`Removing ${chalk.underline(accessory.context.name)} (id: ${accessory.context.id}, uuid: ${accessory.context.uuid}) accessory ...`); this.#log.debug(`${chalk.underline(accessory.context.name)} (id: ${accessory.context.id}, uuid: ${accessory.context.uuid}) is removed because ${reason}.`); this.#accessories = this.#accessories.filter((existingAccessory) => existingAccessory.context.uuid !== accessory.context.uuid); this.#api.unregisterPlatformAccessories('homebridge-adt-pulse', 'ADTPulse', [accessory]); } printSystemInformation() { const homebridgeVersion = chalk.yellowBright(`v${this.#api.serverVersion}`); const nodeVersion = chalk.blueBright(`v${versions.node}`); const opensslVersion = chalk.magentaBright(`v${versions.openssl}`); const packageVersion = chalk.greenBright(`v${getPackageVersion()}`); const platformPlusArch = chalk.redBright(`${platform} (${arch})`); this.#log.info([ `running on ${platformPlusArch}`, `homebridge-adt-pulse ${packageVersion}`, `homebridge ${homebridgeVersion}`, `node ${nodeVersion}`, `openssl ${opensslVersion}`, ].join(chalk.gray(' // '))); } synchronize() { this.#state.intervals.synchronize = setInterval(async () => { if (this.#state.activity.isSyncing) { return; } if (this.#instance === null) { this.#log.warn('synchronize() was called but API is not available.'); return; } try { let currentTimestamp = Date.now(); this.#state.activity.isSyncing = true; if (currentTimestamp - this.#state.lastRunOn.adtLastLogin >= this.#constants.intervalTimestamps.adtSessionLifespan && this.#state.lastRunOn.adtLastLogin !== 0) { this.#log.debug('Login session requires a reset. Resetting the login session now ...'); this.#instance.resetSession(); } if (!this.#instance.isAuthenticated()) { if (!this.#state.activity.isLoggingIn) { this.#state.activity.isLoggingIn = true; const login = await this.#instance.login(); if (login.success) { currentTimestamp = Date.now(); this.#state.lastRunOn.adtKeepAlive = currentTimestamp; this.#state.lastRunOn.adtLastLogin = currentTimestamp; this.#state.lastRunOn.adtSyncCheck = currentTimestamp; } if (!login.success) { this.#state.eventCounters.failedLogins += 1; const attemptsLeft = this.#constants.maxLoginRetries - this.#state.eventCounters.failedLogins; if (attemptsLeft > 0) { this.#log.error(`Login attempt has failed. Trying ${attemptsLeft} more ${getPluralForm(attemptsLeft, 'time', 'times')} ...`); } else { const suspendMinutes = this.#constants.intervalTimestamps.suspendSyncing / 1000 / 60; this.#log.error(`Login attempt has failed for ${this.#constants.maxLoginRetries} ${getPluralForm(this.#constants.maxLoginRetries, 'time', 'times')}. Sleeping for ${suspendMinutes} ${getPluralForm(suspendMinutes, 'minute', 'minutes')} before resuming ...`); if (isMaintenancePeriod()) { this.#log.warn('The web portal may be undergoing an unwarranted maintenance period at this time. The plugin will self-recover.'); } } stackTracer('api-response', login); } this.#state.activity.isLoggingIn = false; } if (this.#state.eventCounters.failedLogins >= this.#constants.maxLoginRetries) { await sleep(this.#constants.intervalTimestamps.suspendSyncing); this.#state.eventCounters.failedLogins = 0; return; } if (!this.#instance.isAuthenticated()) { return; } } currentTimestamp = Date.now(); if (currentTimestamp - this.#state.lastRunOn.adtKeepAlive >= this.#constants.intervalTimestamps.adtKeepAlive && !this.#state.activity.isAdtKeepingAlive) { this.#log.debug('Login session requires a keep alive ping. Initiating a keep alive request now ...'); this.synchronizeKeepAlive(); } if (currentTimestamp - this.#state.lastRunOn.adtSyncCheck >= this.#constants.intervalTimestamps.adtSyncCheck && !this.#state.activity.isAdtSyncChecking) { this.#log.debug('Login session requires a sync check. Running a sync check request now ...'); this.synchronizeSyncCheck(); } } catch (error) { this.#log.error('synchronize() has unexpectedly thrown an error, will continue to sync.'); stackTracer('serialize-error', serializeError(error)); } finally { this.#state.activity.isSyncing = false; } }, this.#constants.intervalTimestamps.synchronize); } synchronizeKeepAlive() { (async () => { if (this.#state.activity.isAdtKeepingAlive) { return; } if (this.#instance === null) { this.#log.warn('synchronizeKeepAlive() was called but API instance is not available.'); return; } try { this.#state.activity.isAdtKeepingAlive = true; const keepAlive = await this.#instance.performKeepAlive(); if (keepAlive.success) { this.#log.debug('Keep alive request was successful. The login session should now be extended.'); } if (!keepAlive.success) { const { error } = keepAlive.info; const { code } = error ?? {}; if (code !== undefined) { switch (code) { case 'ECONNABORTED': this.#log.debug('Keeping alive attempt has failed because the connection timed out. Trying again later.'); break; default: this.#log.debug(`Keeping alive attempt has failed because the response code was "${code}". Trying again later.`); break; } } else { this.#log.error('Keeping alive attempt has failed. Trying again later.'); stackTracer('api-response', keepAlive); } } this.#state.lastRunOn.adtKeepAlive = Date.now(); } catch (error) { this.#log.error('synchronizeKeepAlive() has unexpectedly thrown an error, will continue to keep alive.'); stackTracer('serialize-error', serializeError(error)); } finally { this.#state.activity.isAdtKeepingAlive = false; } })(); } synchronizeSyncCheck() { (async () => { if (this.#state.activity.isAdtSyncChecking) { return; } if (this.#instance === null) { this.#log.warn('synchronizeSyncCheck() was called but API instance is not available.'); return; } try { this.#state.activity.isAdtSyncChecking = true; const syncCheck = await this.#instance.performSyncCheck(); if (syncCheck.success) { this.#log.debug('Sync check request was successful. Determining if panel and sensor data is outdated ...'); if (syncCheck.info.syncCode !== this.#state.data.syncCode) { this.#log.debug(`Panel and sensor data is outdated (cached: ${this.#state.data.syncCode}, fetched: ${syncCheck.info.syncCode}). Retrieving the latest data ...`); this.#state.data.syncCode = syncCheck.info.syncCode; await this.fetchUpdatedInformation(); } else { this.#log.debug(`Panel and sensor data is up to date (cached: ${this.#state.data.syncCode}, fetched: ${syncCheck.info.syncCode}). No need to retrieve the latest data.`); } } if (!syncCheck.success) { const { error } = syncCheck.info; const { code } = error ?? {}; if (code !== undefined) { switch (code) { case 'ECONNABORTED': this.#log.debug('Sync checking attempt has failed because the connection timed out. Trying again later.'); break; case 'ECONNRESET': this.#log.debug('Sync checking attempt has failed because the connection was reset. Trying again later.'); break; default: this.#log.debug(`Sync checking attempt has failed because the response code was "${code}". Trying again later.`); break; } } else { this.#log.error('Sync checking attempt has failed. Trying again later.'); stackTracer('api-response', syncCheck); } } this.#state.lastRunOn.adtSyncCheck = Date.now(); } catch (error) { this.#log.error('synchronizeSyncCheck() has unexpectedly thrown an error, will continue to sync check.'); stackTracer('serialize-error', serializeError(error)); } finally { this.#state.activity.isAdtSyncChecking = false; } })(); } async fetchUpdatedInformation() { if (this.#instance === null) { this.#log.warn('fetchUpdatedInformation() was called but API instance is not available.'); return; } const cachedState = _.clone(this.#state.data); try { const requests = await Promise.all([ this.#instance.getGatewayInformation(), this.#instance.getPanelInformation(), this.#instance.getPanelStatus(), this.#instance.getSensorsInformation(), this.#instance.getSensorsStatus(), this.#instance.getOrbSecurityButtons(), ]); if (requests[0].success) { const { info } = requests[0]; this.#state.data.gatewayInfo = info; } if (requests[1].success) { const { info } = requests[1]; this.#state.data.panelInfo = info; } if (requests[2].success) { const { info } = requests[2]; this.#state.data.panelStatus = info; } if (requests[3].success) { const { sensors } = requests[3].info; this.#state.data.sensorsInfo = sensors; } if (requests[4].success) { const { sensors } = requests[4].info; this.#state.data.sensorsStatus = sensors; } if (requests[5].success) { const { info } = requests[5]; this.#state.data.orbSecurityButtons = info; } await this.logStatusChanges(cachedState, this.#state.data); await this.unknownInformationDispatcher(); await this.unifyDevices(); } catch (error) { this.#log.error('fetchUpdatedInformation() has unexpectedly thrown an error, will continue to fetch.'); stackTracer('serialize-error', serializeError(error)); } } async logStatusChanges(oldCache, newCache) { if (oldCache.gatewayInfo !== null && newCache.gatewayInfo !== null) { const oldStatus = oldCache.gatewayInfo.status; const newStatus = newCache.gatewayInfo.status; if (oldStatus !== newStatus && oldStatus !== null && newStatus !== null) { this.#log.info(`${chalk.underline('ADT Pulse Gateway')} status changed (old: "${oldStatus}", new: "${newStatus}").`); } } if (oldCache.panelInfo !== null && newCache.panelInfo !== null) { const oldStatus = oldCache.panelInfo.status; const newStatus = newCache.panelInfo.status; if (oldStatus !== newStatus && oldStatus !== null && newStatus !== null) { this.#log.info(`${chalk.underline('Security Panel')} status changed (old: "${oldStatus}", new: "${newStatus}").`); } } if (oldCache.panelStatus !== null && newCache.panelStatus !== null) { const oldStatus = oldCache.panelStatus.rawData.node; const newStatus = newCache.panelStatus.rawData.node; const splitOldStatus = oldStatus.split(textOrbTextSummarySections).filter(Boolean).join(' / '); const splitNewStatus = newStatus.split(textOrbTextSummarySections).filter(Boolean).join(' / '); if (oldStatus !== newStatus) { this.#log.info(`${chalk.underline('Security Panel')} state changed (old: "${splitOldStatus}", new: "${splitNewStatus}").`); } } if (this.#config !== null && this.#config.sensors.length > 0 && oldCache.sensorsInfo.length !== 0 && newCache.sensorsInfo !== null) { if (oldCache.sensorsInfo.length === newCache.sensorsInfo.length) { for (let i = 0; i < oldCache.sensorsInfo.length; i += 1) { const { name, zone } = oldCache.sensorsInfo[i]; const configuredSensor = this.#config.sensors.find((sensor) => sensor.adtName === name && sensor.adtZone === zone); const oldStatus = oldCache.sensorsInfo[i].status; const newStatus = newCache.sensorsInfo[i].status; if (configuredSensor !== undefined && oldStatus !== newStatus) { this.#log.info(`${chalk.underline(configuredSensor.name ?? configuredSensor.adtName)} status changed (old: "${oldStatus}", new: "${newStatus}").`); } } } else { this.#log.warn('Changes to sensors information cannot be determined due to length inconsistencies.'); stackTracer('log-status-changes', { old: oldCache.sensorsInfo, new: newCache.sensorsInfo, }); } } if (this.#config !== null && this.#config.sensors.length > 0 && oldCache.sensorsStatus.length !== 0 && newCache.sensorsStatus !== null) { if (oldCache.sensorsStatus.length === newCache.sensorsStatus.length) { for (let i = 0; i < oldCache.sensorsStatus.length; i += 1) { const { name, zone } = oldCache.sensorsStatus[i]; const configuredSensor = this.#config.sensors.find((sensor) => sensor.adtName === name && sensor.adtZone === zone); const oldStatus = oldCache.sensorsStatus[i].statuses.join(', '); const newStatus = newCache.sensorsStatus[i].statuses.join(', '); if (configuredSensor !== undefined && oldStatus !== newStatus) { this.#log.info(`${chalk.underline(configuredSensor.name ?? configuredSensor.adtName)} state changed (old: "${oldStatus}", new: "${newStatus}").`); } } } else { this.#log.warn('Changes to sensors status cannot be determined due to length inconsistencies.'); stackTracer('log-status-changes', { old: oldCache.sensorsStatus, new: newCache.sensorsStatus, }); } } } async unknownInformationDispatcher() { const { sensorsInfo, sensorsStatus } = this.#state.data; const sensors = sensorsInfo.reduce((allSensors, currentSensor) => { const matchedStatus = sensorsStatus.find((sensorStatus) => currentSensor.zone === sensorStatus.zone); if (matchedStatus !== undefined) { allSensors.push({ info: currentSensor, status: matchedStatus, type: condenseSensorType(currentSensor.deviceType), }); } return allSensors; }, []); const dataHash = generateHash(sensors); if (this.#state.reportedHashes.find((reportedHash) => dataHash === reportedHash) === undefined) { const detectedNew = await detectPlatformUnknownSensorsAction(sensors, this.#log, this.#debugMode); if (detectedNew) { this.#state.reportedHashes.push(dataHash); } } } async unifyDevices() { const { gatewayInfo, panelInfo, sensorsInfo } = this.#state.data; const devices = []; if (gatewayInfo !== null) { const id = 'adt-device-0'; devices.push({ id, name: 'Gateway', originalName: 'Gateway', type: 'gateway', zone: null, category: 'OTHER', manufacturer: gatewayInfo.manufacturer, model: gatewayInfo.model, serial: gatewayInfo.serialNumber, firmware: gatewayInfo.versions.firmware, hardware: gatewayInfo.versions.hardware, software: getPackageVersion(), uuid: this.#api.hap.uuid.generate(id), }); } if (panelInfo !== null) { const idPanel = 'adt-device-1'; const idSwitch = 'adt-device-1-switch'; devices.push({ id: idPanel, name: 'Security Panel', originalName: 'Security Panel', type: 'panel', zone: null, category: 'SECURITY_SYSTEM', manufacturer: panelInfo.manufacturer, model: panelInfo.model, serial: 'N/A', firmware: null, hardware: null, software: getPackageVersion(), uuid: this.#api.hap.uuid.generate(idPanel), }); if (this.#config !== null && !this.#config.options.includes('disableAlarmRingingSwitch')) { devices.push({ id: idSwitch, name: 'Alarm Ringing', originalName: 'Alarm Ringing', type: 'panelSwitch', zone: null, category: 'SWITCH', manufacturer: 'ADT Pulse for Homebridge', model: 'N/A', serial: 'N/A', firmware: null, hardware: null, software: getPackageVersion(), uuid: this.#api.hap.uuid.generate(idSwitch), }); } } if (this.#config !== null && sensorsInfo !== null) { for (let i = 0; i < this.#config.sensors.length; i += 1) { const { adtName, adtType, adtZone, name, } = this.#config.sensors[i]; const sensor = sensorsInfo.find((sensorInfo) => { const sensorInfoName = sensorInfo.name; const sensorInfoType = condenseSensorType(sensorInfo.deviceType); const sensorInfoZone = sensorInfo.zone; return (adtName === sensorInfoName && adtType === sensorInfoType && adtZone === sensorInfoZone); }); if (sensor === undefined) { this.#log.warn(`Attempted to add or update ${chalk.underline(name ?? adtName)} (adtName: ${adtName}, adtZone: ${adtZone}, adtType: ${adtType}) accessory that does not exist on the portal.`); continue; } const id = `adt-device-${sensor.deviceId}`; devices.push({ id, name: name ?? adtName, originalName: adtName, zone: adtZone, type: adtType, category: 'SENSOR', manufacturer: 'ADT', model: sensor.deviceType, serial: null, firmware: null, hardware: null, software: getPackageVersion(), uuid: this.#api.hap.uuid.generate(id), }); } } if (this.#config !== null) { const { options, sensors } = this.#config; for (let i = this.#accessories.length - 1; i >= 0; i -= 1) { const { originalName, type, zone } = this.#accessories[i].context; if (type === 'panelSwitch' && options.includes('disableAlarmRingingSwitch')) { this.removeAccessory(this.#accessories[i], 'user disabled this feature'); } if (type === 'gateway' || type === 'panel' || type === 'panelSwitch') { continue; } if (!sensors.some((sensor) => originalName === sensor.adtName && zone !== null && zone === sensor.adtZone)) { this.removeAccessory(this.#accessories[i], 'accessory is missing in config'); } } } await this.pollAccessories(devices); } async pollAccessories(devices) { for (let i = 0; i < devices.length; i += 1) { const accessoryIndex = this.#accessories.findIndex((accessory) => devices[i].uuid === accessory.context.uuid); if (accessoryIndex >= 0) { this.updateAccessory(devices[i]); } else { this.addAccessory(devices[i]); } } } } //# sourceMappingURL=platform.js.map