homebridge-adt-pulse
Version:
Homebridge security system platform for ADT Pulse
630 lines • 31.8 kB
JavaScript
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)} 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)} 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}, 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