diff --git a/README.md b/README.md index 83a288b..018890a 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,6 @@ ADT Pulse for Homebridge ========================= -### ⚠️ Please Install the Beta Version ⚠️ -This plugin is completely re-written from the ground up (supports v27.0.0-140), and I would love everyone on board! Please install the beta version, so I can quickly get a faster and more stable version to you! - -Please bear with me, as the beta version is being actively developed and tested. If you see any unusual or annoying bugs, please comment on this [GitHub issue](https://github.com/mrjackyliang/homebridge-adt-pulse/issues/124). - [![NPM Package](https://img.shields.io/npm/v/homebridge-adt-pulse?style=flat-square&logo=npm&logoColor=%23ffffff&color=%23b25da6)](https://www.npmjs.com/package/homebridge-adt-pulse) [![NPM Downloads](https://img.shields.io/npm/dt/homebridge-adt-pulse?style=flat-square&logo=npm&logoColor=%23ffffff&color=%236688c3)](https://www.npmjs.com/package/homebridge-adt-pulse) [![GitHub License](https://img.shields.io/github/license/mrjackyliang/homebridge-adt-pulse?style=flat-square&logo=googledocs&logoColor=%23ffffff&color=%2348a56a)](https://github.com/mrjackyliang/homebridge-adt-pulse/blob/main/LICENSE) @@ -71,7 +66,8 @@ This plugin can expose these devices (in read-only mode) based on your configura 6. `heat` - Heat (Rate-of-Rise) Detector 7. `motion` - Motion Sensor __::__ Motion Sensor (Notable Events Only) 8. `shock` - Shock Sensor -9. `temperature` - Temperature Sensor +9. `supervisory` - System/Supervisory +10. `temperature` - Temperature Sensor Due to implementation complexity and platform instability, all Z-Wave accessories connected to the ADT Pulse gateway will not be planned for development or be supported overall. Consider purchasing the [Hubitat Hub](https://hubitat.com) for a seamless setup experience, or read about the [Home Assistant Z-Wave](https://www.home-assistant.io/integrations/zwave_js/) integration. @@ -162,6 +158,14 @@ Consumers would enable debug mode, but forget to also enable Homebridge debug mo To improve this, debug mode is now activated __ONLY when debug mode is enabled on Homebridge__ itself. This approach promotes isolation (logs can be separated for each bridge) and helps enhance the troubleshooting experience in case any issues arise. +## Temperature Sensors in HAP Protocol +The Temperature Sensor (`temperature`) functions differently compared to standard contact sensors when it comes to processing sensor statuses. + +In contrast to typical contact sensors that convey open or closed status, the temperature sensor exposed in the Home app (utilizing the HAP protocol) operates with temperature values. To accommodate this difference, the accessory converts these binary states into corresponding temperature degrees: +- Cold temperatures are represented as __0°C__. +- Normal temperatures are indicated as __20°C__. +- Hot temperatures are reflected as __40°C__. + ## Support for HOOBS Please note that HOOBS may use an outdated configuration UI. This issue that was reported by me, remains unresolved by the HOOBS team. For additional details, refer to this [GitHub issue](https://github.com/hoobs-org/HOOBS/issues/1873). diff --git a/config.schema.json b/config.schema.json index 804a689..74ad326 100644 --- a/config.schema.json +++ b/config.schema.json @@ -205,6 +205,12 @@ "shock" ] }, + { + "title": "System/Supervisory", + "enum": [ + "supervisory" + ] + }, { "title": "Temperature Sensor", "enum": [ diff --git a/package.json b/package.json index 4896aad..68d5125 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "homebridge-adt-pulse", "displayName": "Homebridge ADT Pulse", - "version": "3.0.0-beta.23", + "version": "3.0.0-beta.24", "description": "Homebridge security system platform for ADT Pulse", "main": "./build/src/index.js", "exports": "./build/src/index.js", diff --git a/src/lib/accessory.ts b/src/lib/accessory.ts index e553e43..aea7cf3 100644 --- a/src/lib/accessory.ts +++ b/src/lib/accessory.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; import { condensedSensorTypeItems } from '@/lib/items.js'; -import { condensePanelStates, stackTracer } from '@/lib/utility.js'; +import { condensePanelStates, isPanelAlarmActive, stackTracer } from '@/lib/utility.js'; import type { ADTPulseAccessoryAccessory, ADTPulseAccessoryApi, @@ -13,10 +13,8 @@ import type { ADTPulseAccessoryConstructorLog, ADTPulseAccessoryConstructorService, ADTPulseAccessoryConstructorState, - ADTPulseAccessoryGetPanelStatusCaller, ADTPulseAccessoryGetPanelStatusMode, ADTPulseAccessoryGetPanelStatusReturns, - ADTPulseAccessoryGetSensorStatusCaller, ADTPulseAccessoryGetSensorStatusMode, ADTPulseAccessoryGetSensorStatusReturns, ADTPulseAccessoryInstance, @@ -25,6 +23,7 @@ import type { ADTPulseAccessorySetPanelStatusArm, ADTPulseAccessorySetPanelStatusReturns, ADTPulseAccessoryState, + ADTPulseAccessoryStatus, ADTPulseAccessoryUpdaterReturns, } from '@/types/index.d.ts'; @@ -97,6 +96,15 @@ export class ADTPulseAccessory { */ #state: ADTPulseAccessoryState; + /** + * ADT Pulse Accessory - Status. + * + * @private + * + * @since 1.0.0 + */ + #status: ADTPulseAccessoryStatus; + /** * ADT Pulse Accessory - Constructor. * @@ -118,19 +126,21 @@ export class ADTPulseAccessory { this.#log = log; this.#services = {}; this.#state = state; + this.#status = { + isBusy: false, + setValue: null, + }; const { context } = this.#accessory; const { firmware, hardware, - id, manufacturer, model, name, serial, software, type, - uuid, } = context; // Set the "AccessoryInformation" service. @@ -185,143 +195,11 @@ export class ADTPulseAccessory { case 'shock': this.#services.Primary = this.#accessory.getService(service.OccupancySensor) ?? this.#accessory.addService(service.OccupancySensor); break; - case 'temperature': - this.#services.Primary = this.#accessory.getService(service.TemperatureSensor) ?? this.#accessory.addService(service.TemperatureSensor); - break; - default: - break; - } - - // Check for missing services. - if (this.#services.Primary === undefined) { - // The "gateway" accessory does not need to be initialized further. - if (type !== 'gateway') { - this.#log.error(`Failed to initialize ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory because the primary service does not exist ...`); - } - - return; - } - - // Set the characteristics associated with the gateway/panel (required). - switch (type) { - case 'gateway': - // No supported characteristic available. - break; - case 'panel': - this.#services.Primary.getCharacteristic(this.#characteristic.SecuritySystemCurrentState) - .onGet(() => this.getPanelStatus('constructor', 'current')) - .updateValue(this.getPanelStatus('updater', 'current')); - - this.#services.Primary.getCharacteristic(this.#characteristic.SecuritySystemTargetState) - .onGet(() => this.getPanelStatus('constructor', 'target')) - .updateValue(this.getPanelStatus('updater', 'target')); - - this.#services.Primary.getCharacteristic(this.#characteristic.SecuritySystemTargetState) - .onSet(async (value) => this.setPanelStatus(value)); - break; - default: - break; - } - - // Set the characteristics associated with the gateway/panel (optional). - switch (type) { - case 'gateway': - // No supported characteristic available. - break; - case 'panel': - this.#services.Primary.getCharacteristic(this.#characteristic.SecuritySystemAlarmType) - .onGet(() => this.getPanelStatus('constructor', 'alarmType')) - .updateValue(this.getPanelStatus('updater', 'alarmType')); - - this.#services.Primary.getCharacteristic(this.#characteristic.StatusFault) - .onGet(() => this.getPanelStatus('constructor', 'fault')) - .updateValue(this.getPanelStatus('updater', 'fault')); - - this.#services.Primary.getCharacteristic(this.#characteristic.StatusTampered) - .onGet(() => this.getPanelStatus('constructor', 'tamper')) - .updateValue(this.getPanelStatus('updater', 'tamper')); - break; - default: - break; - } - - // Set the characteristics associated with the sensor (required). - switch (type) { - case 'co': - this.#services.Primary.getCharacteristic(this.#characteristic.CarbonMonoxideDetected) - .onGet(() => this.getSensorStatus('constructor', 'status')) - .updateValue(this.getSensorStatus('updater', 'status')); - break; - case 'doorWindow': - this.#services.Primary.getCharacteristic(this.#characteristic.ContactSensorState) - .onGet(() => this.getSensorStatus('constructor', 'status')) - .updateValue(this.getSensorStatus('updater', 'status')); - break; - case 'fire': - this.#services.Primary.getCharacteristic(this.#characteristic.SmokeDetected) - .onGet(() => this.getSensorStatus('constructor', 'status')) - .updateValue(this.getSensorStatus('updater', 'status')); - break; - case 'flood': - this.#services.Primary.getCharacteristic(this.#characteristic.LeakDetected) - .onGet(() => this.getSensorStatus('constructor', 'status')) - .updateValue(this.getSensorStatus('updater', 'status')); - break; - case 'glass': - this.#services.Primary.getCharacteristic(this.#characteristic.OccupancyDetected) - .onGet(() => this.getSensorStatus('constructor', 'status')) - .updateValue(this.getSensorStatus('updater', 'status')); - break; - case 'heat': - this.#services.Primary.getCharacteristic(this.#characteristic.OccupancyDetected) - .onGet(() => this.getSensorStatus('constructor', 'status')) - .updateValue(this.getSensorStatus('updater', 'status')); - break; - case 'motion': - this.#services.Primary.getCharacteristic(this.#characteristic.MotionDetected) - .onGet(() => this.getSensorStatus('constructor', 'status')) - .updateValue(this.getSensorStatus('updater', 'status')); - break; - case 'shock': - this.#services.Primary.getCharacteristic(this.#characteristic.OccupancyDetected) - .onGet(() => this.getSensorStatus('constructor', 'status')) - .updateValue(this.getSensorStatus('updater', 'status')); + case 'supervisory': + this.#services.Primary = this.#accessory.getService(service.OccupancySensor) ?? this.#accessory.addService(service.OccupancySensor); break; case 'temperature': - this.#services.Primary.getCharacteristic(this.#characteristic.CurrentTemperature) - .onGet(() => this.getSensorStatus('constructor', 'status')) - .updateValue(this.getSensorStatus('updater', 'status')); - break; - default: - break; - } - - // Set the characteristics associated with the sensor (optional). - switch (type) { - case 'co': - case 'doorWindow': - case 'fire': - case 'flood': - case 'glass': - case 'heat': - case 'motion': - case 'shock': - case 'temperature': - this.#services.Primary.getCharacteristic(this.#characteristic.StatusActive) - .onGet(() => this.getSensorStatus('constructor', 'active')) - .updateValue(this.getSensorStatus('updater', 'active')); - - this.#services.Primary.getCharacteristic(this.#characteristic.StatusFault) - .onGet(() => this.getSensorStatus('constructor', 'fault')) - .updateValue(this.getSensorStatus('updater', 'fault')); - - this.#services.Primary.getCharacteristic(this.#characteristic.StatusLowBattery) - .onGet(() => this.getSensorStatus('constructor', 'lowBattery')) - .updateValue(this.getSensorStatus('updater', 'lowBattery')); - - this.#services.Primary.getCharacteristic(this.#characteristic.StatusTampered) - .onGet(() => this.getSensorStatus('constructor', 'tamper')) - .updateValue(this.getSensorStatus('updater', 'tamper')); + this.#services.Primary = this.#accessory.getService(service.TemperatureSensor) ?? this.#accessory.addService(service.TemperatureSensor); break; default: break; @@ -361,14 +239,13 @@ export class ADTPulseAccessory { break; case 'panel': this.#services.Primary.getCharacteristic(this.#characteristic.SecuritySystemCurrentState) - .updateValue(this.getPanelStatus('updater', 'current')); + .updateValue(this.getPanelStatus('current')); this.#services.Primary.getCharacteristic(this.#characteristic.SecuritySystemTargetState) - .updateValue(this.getPanelStatus('updater', 'target')); + .updateValue(this.getPanelStatus('target')); - // TODO testing. - this.#services.Primary.updateCharacteristic(this.#characteristic.SecuritySystemCurrentState, this.getPanelStatus('constructor', 'current')); - this.#services.Primary.updateCharacteristic(this.#characteristic.SecuritySystemTargetState, this.getPanelStatus('constructor', 'target')); + this.#services.Primary.getCharacteristic(this.#characteristic.SecuritySystemTargetState) + .onSet(async (value) => this.setPanelStatus(value)); break; default: break; @@ -381,18 +258,13 @@ export class ADTPulseAccessory { break; case 'panel': this.#services.Primary.getCharacteristic(this.#characteristic.SecuritySystemAlarmType) - .updateValue(this.getPanelStatus('updater', 'alarmType')); + .updateValue(this.getPanelStatus('alarmType')); this.#services.Primary.getCharacteristic(this.#characteristic.StatusFault) - .updateValue(this.getPanelStatus('updater', 'fault')); + .updateValue(this.getPanelStatus('fault')); this.#services.Primary.getCharacteristic(this.#characteristic.StatusTampered) - .updateValue(this.getPanelStatus('updater', 'tamper')); - - // TODO testing. - this.#services.Primary.updateCharacteristic(this.#characteristic.SecuritySystemAlarmType, this.getPanelStatus('constructor', 'alarmType')); - this.#services.Primary.updateCharacteristic(this.#characteristic.StatusFault, this.getPanelStatus('constructor', 'fault')); - this.#services.Primary.updateCharacteristic(this.#characteristic.StatusTampered, this.getPanelStatus('constructor', 'tamper')); + .updateValue(this.getPanelStatus('tamper')); break; default: break; @@ -402,65 +274,43 @@ export class ADTPulseAccessory { switch (type) { case 'co': this.#services.Primary.getCharacteristic(this.#characteristic.CarbonMonoxideDetected) - .updateValue(this.getSensorStatus('updater', 'status')); - - this.#services.Primary.updateCharacteristic(this.#characteristic.CarbonMonoxideDetected, this.getSensorStatus('constructor', 'status')); + .updateValue(this.getSensorStatus('status')); break; case 'doorWindow': this.#services.Primary.getCharacteristic(this.#characteristic.ContactSensorState) - .updateValue(this.getSensorStatus('updater', 'status')); - - // TODO testing. - this.#services.Primary.updateCharacteristic(this.#characteristic.ContactSensorState, this.getSensorStatus('constructor', 'status')); + .updateValue(this.getSensorStatus('status')); break; case 'fire': this.#services.Primary.getCharacteristic(this.#characteristic.SmokeDetected) - .updateValue(this.getSensorStatus('updater', 'status')); - - // TODO testing. - this.#services.Primary.updateCharacteristic(this.#characteristic.SmokeDetected, this.getSensorStatus('constructor', 'status')); + .updateValue(this.getSensorStatus('status')); break; case 'flood': this.#services.Primary.getCharacteristic(this.#characteristic.LeakDetected) - .updateValue(this.getSensorStatus('updater', 'status')); - - // TODO testing. - this.#services.Primary.updateCharacteristic(this.#characteristic.LeakDetected, this.getSensorStatus('constructor', 'status')); + .updateValue(this.getSensorStatus('status')); break; case 'glass': this.#services.Primary.getCharacteristic(this.#characteristic.OccupancyDetected) - .updateValue(this.getSensorStatus('updater', 'status')); - - // TODO testing. - this.#services.Primary.updateCharacteristic(this.#characteristic.OccupancyDetected, this.getSensorStatus('constructor', 'status')); + .updateValue(this.getSensorStatus('status')); break; case 'heat': this.#services.Primary.getCharacteristic(this.#characteristic.OccupancyDetected) - .updateValue(this.getSensorStatus('updater', 'status')); - - // TODO testing. - this.#services.Primary.updateCharacteristic(this.#characteristic.OccupancyDetected, this.getSensorStatus('constructor', 'status')); + .updateValue(this.getSensorStatus('status')); break; case 'motion': this.#services.Primary.getCharacteristic(this.#characteristic.MotionDetected) - .updateValue(this.getSensorStatus('updater', 'status')); - - // TODO testing. - this.#services.Primary.updateCharacteristic(this.#characteristic.MotionDetected, this.getSensorStatus('constructor', 'status')); + .updateValue(this.getSensorStatus('status')); break; case 'shock': this.#services.Primary.getCharacteristic(this.#characteristic.OccupancyDetected) - .updateValue(this.getSensorStatus('updater', 'status')); - - // TODO testing. - this.#services.Primary.updateCharacteristic(this.#characteristic.OccupancyDetected, this.getSensorStatus('constructor', 'status')); + .updateValue(this.getSensorStatus('status')); + break; + case 'supervisory': + this.#services.Primary.getCharacteristic(this.#characteristic.OccupancyDetected) + .updateValue(this.getSensorStatus('status')); break; case 'temperature': this.#services.Primary.getCharacteristic(this.#characteristic.CurrentTemperature) - .updateValue(this.getSensorStatus('updater', 'status')); - - // TODO testing. - this.#services.Primary.updateCharacteristic(this.#characteristic.CurrentTemperature, this.getSensorStatus('constructor', 'status')); + .updateValue(this.getSensorStatus('status')); break; default: break; @@ -476,24 +326,19 @@ export class ADTPulseAccessory { case 'heat': case 'motion': case 'shock': + case 'supervisory': case 'temperature': this.#services.Primary.getCharacteristic(this.#characteristic.StatusActive) - .updateValue(this.getSensorStatus('updater', 'active')); + .updateValue(this.getSensorStatus('active')); this.#services.Primary.getCharacteristic(this.#characteristic.StatusFault) - .updateValue(this.getSensorStatus('updater', 'fault')); + .updateValue(this.getSensorStatus('fault')); this.#services.Primary.getCharacteristic(this.#characteristic.StatusLowBattery) - .updateValue(this.getSensorStatus('updater', 'lowBattery')); + .updateValue(this.getSensorStatus('lowBattery')); this.#services.Primary.getCharacteristic(this.#characteristic.StatusTampered) - .updateValue(this.getSensorStatus('updater', 'tamper')); - - // TODO testing. - this.#services.Primary.updateCharacteristic(this.#characteristic.StatusActive, this.getSensorStatus('constructor', 'active')); - this.#services.Primary.updateCharacteristic(this.#characteristic.StatusFault, this.getSensorStatus('constructor', 'fault')); - this.#services.Primary.updateCharacteristic(this.#characteristic.StatusLowBattery, this.getSensorStatus('constructor', 'lowBattery')); - this.#services.Primary.updateCharacteristic(this.#characteristic.StatusTampered, this.getSensorStatus('constructor', 'tamper')); + .updateValue(this.getSensorStatus('tamper')); break; default: break; @@ -503,8 +348,7 @@ export class ADTPulseAccessory { /** * ADT Pulse Accessory - Get sensor status. * - * @param {ADTPulseAccessoryGetSensorStatusCaller} caller - Caller. - * @param {ADTPulseAccessoryGetSensorStatusMode} mode - Mode. + * @param {ADTPulseAccessoryGetSensorStatusMode} mode - Mode. * * @private * @@ -512,7 +356,7 @@ export class ADTPulseAccessory { * * @since 1.0.0 */ - private getSensorStatus(caller: Caller, mode: ADTPulseAccessoryGetSensorStatusMode): ADTPulseAccessoryGetSensorStatusReturns { + private getSensorStatus(mode: ADTPulseAccessoryGetSensorStatusMode): ADTPulseAccessoryGetSensorStatusReturns { const { context } = this.#accessory; const { id, @@ -525,7 +369,7 @@ export class ADTPulseAccessory { const matchedSensorStatus = this.#state.data.sensorsStatus.find((sensorStatus) => originalName === sensorStatus.name && zone !== null && sensorStatus.zone === zone); - let hapStatus = new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.SUCCESS); + let hapStatus; // If sensor is not found or sensor type is not supported. if ( @@ -538,13 +382,7 @@ export class ADTPulseAccessory { this.#log.error(`Attempted to get sensor status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but sensor is not found or sensor type is not supported.`); - // The error message is either thrown or returned depending on the caller. - switch (caller) { - case 'updater': - return hapStatus as ADTPulseAccessoryGetSensorStatusReturns; - default: - throw hapStatus; - } + return hapStatus; } const { icon, statuses } = matchedSensorStatus; @@ -552,18 +390,22 @@ export class ADTPulseAccessory { // Find the state for "Status Active" (optional characteristic). if (mode === 'active') { // If status or icon does not include these, the sensor is active. - return !statuses.includes('Offline') + return ( + !statuses.includes('Offline') && !statuses.includes('Unknown') && icon !== 'devStatOffline' - && icon !== 'devStatUnknown'; + && icon !== 'devStatUnknown' + ); } // Find the state for "Status Fault" (optional characteristic). if (mode === 'fault') { // If status or icon includes these, the sensor has a fault. if ( - statuses.includes('Bypassed') + statuses.includes('ALARM') + || statuses.includes('Bypassed') || statuses.includes('Trouble') + || icon === 'devStatAlarm' ) { return this.#characteristic.StatusFault.GENERAL_FAULT; } @@ -596,8 +438,8 @@ export class ADTPulseAccessory { // Find the state for the sensor (required characteristic). switch (type) { - case 'co': - if (statuses.includes('Tripped')) { // TODO Not in sensor action items. + case 'co': // TODO Not fully tested. + if (statuses.includes('Tripped')) { return this.#characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL; } @@ -606,7 +448,7 @@ export class ADTPulseAccessory { } break; case 'doorWindow': - if (statuses.includes('Open')) { + if (statuses.includes('ALARM') || statuses.includes('Open')) { return this.#characteristic.ContactSensorState.CONTACT_NOT_DETECTED; } @@ -615,17 +457,21 @@ export class ADTPulseAccessory { } break; case 'fire': + if (statuses.includes('ALARM') || statuses.includes('Tripped')) { + return this.#characteristic.SmokeDetected.SMOKE_DETECTED; + } + if (statuses.includes('Okay')) { return this.#characteristic.SmokeDetected.SMOKE_NOT_DETECTED; } break; - case 'flood': + case 'flood': // TODO Not fully tested. if (statuses.includes('Okay')) { return this.#characteristic.LeakDetected.LEAK_NOT_DETECTED; } break; case 'glass': - if (statuses.includes('Tripped')) { // TODO Not in sensor action items. + if (statuses.includes('ALARM') || statuses.includes('Tripped')) { return this.#characteristic.OccupancyDetected.OCCUPANCY_DETECTED; } @@ -633,34 +479,40 @@ export class ADTPulseAccessory { return this.#characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED; } break; - case 'heat': + case 'heat': // TODO Not fully tested. if (statuses.includes('Okay')) { return this.#characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED; } break; case 'motion': - if (statuses.includes('No Motion') || statuses.includes('Okay')) { - return false; + if (statuses.includes('ALARM') || statuses.includes('Motion')) { + return true; } - if (statuses.includes('Motion')) { - return true; + if (statuses.includes('No Motion') || statuses.includes('Okay')) { + return false; } break; - case 'shock': - // TODO: Nothing done here yet. + case 'shock': // TODO Not fully tested. break; - case 'temperature': + case 'supervisory': // TODO Not fully tested. + break; + case 'temperature': // TODO Not fully tested. /** - * Since there isn't a binary way to represent temperature - * sensors, because sensors from ADT do not show - * exact temperatures and this service doesn't support showing - * it that way, we can represent this using Celsius. + * Since sensors from ADT do not show exact temperatures + * the way how it likes and HomeKit does not support showing statuses + * in a binary way, we will convert them to Celsius instead. * * - If temperature is cold, we represent it with 0. * - If temperature is normal, we represent it with 20. * - If temperature is hot, we represent it with 40. + * + * @since 1.0.0 */ + if (statuses.includes('ALARM') || statuses.includes('Tripped')) { + return 0; + } + if (statuses.includes('Okay')) { return 20; } @@ -675,33 +527,21 @@ export class ADTPulseAccessory { this.#log.warn(`Attempted to get sensor status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but sensor is currently "Offline" or "Unknown".`); - // The error message is either thrown or returned depending on the caller. - switch (caller) { - case 'updater': - return hapStatus as ADTPulseAccessoryGetSensorStatusReturns; - default: - throw hapStatus; - } + return hapStatus; } + // Attempted to get sensor status, but actions have not been implemented yet. hapStatus = new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST); this.#log.warn(`Attempted to get sensor status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but actions have not been implemented yet.`); - // The error message is either thrown or returned depending on the caller. - switch (caller) { - case 'updater': - return hapStatus as ADTPulseAccessoryGetSensorStatusReturns; - default: - throw hapStatus; - } + return hapStatus; } /** * ADT Pulse Accessory - Get panel status. * - * @param {ADTPulseAccessoryGetPanelStatusCaller} caller - Caller. - * @param {ADTPulseAccessoryGetPanelStatusMode} mode - Mode. + * @param {ADTPulseAccessoryGetPanelStatusMode} mode - Mode. * * @private * @@ -709,7 +549,7 @@ export class ADTPulseAccessory { * * @since 1.0.0 */ - private getPanelStatus(caller: Caller, mode: ADTPulseAccessoryGetPanelStatusMode): ADTPulseAccessoryGetPanelStatusReturns { + private getPanelStatus(mode: ADTPulseAccessoryGetPanelStatusMode): ADTPulseAccessoryGetPanelStatusReturns { const { context } = this.#accessory; const { id, @@ -718,7 +558,7 @@ export class ADTPulseAccessory { uuid, } = context; - let hapStatus = new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.SUCCESS); + let hapStatus; // If device is not a security panel. if (type !== 'panel') { @@ -726,13 +566,7 @@ export class ADTPulseAccessory { this.#log.error(`Attempted to get panel status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but device is not a security panel.`); - // The error message is either thrown or returned depending on the caller. - switch (caller) { - case 'updater': - return hapStatus as ADTPulseAccessoryGetSensorStatusReturns; - default: - throw hapStatus; - } + return hapStatus; } // If panel status has not been retrieved yet. @@ -745,26 +579,14 @@ export class ADTPulseAccessory { this.#log.debug(`Attempted to get panel status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but panel status has not been retrieved yet.`); - // The error message is either thrown or returned depending on the caller. - switch (caller) { - case 'updater': - return hapStatus as ADTPulseAccessoryGetSensorStatusReturns; - default: - throw hapStatus; - } + return hapStatus; } const { panelStates, panelStatuses } = this.#state.data.panelStatus; // Find the state for "Security System Alarm Type" (optional characteristic). if (mode === 'alarmType') { - if ( - panelStatuses.includes('BURGLARY ALARM') - || panelStatuses.includes('Carbon Monoxide Alarm') - || panelStatuses.includes('FIRE ALARM') - || panelStatuses.includes('Uncleared Alarm') - || panelStatuses.includes('WATER ALARM') - ) { + if (isPanelAlarmActive(panelStatuses)) { return 1; } @@ -782,24 +604,30 @@ export class ADTPulseAccessory { // Find the state for "Status Tampered" (optional characteristic). if (mode === 'tamper') { - // TODO: Not enough statuses currently to determine whether system is tampered or not. + if (panelStatuses.includes('Sensor Problem') || panelStatuses.includes('Sensor Problems')) { + return this.#characteristic.StatusTampered.TAMPERED; + } + return this.#characteristic.StatusTampered.NOT_TAMPERED; } - // Find the current state for the panel (required characteristic). + /** + * Find the current state for the panel (required characteristic). + * + * Notes: + * - If system is busy setting the state, HomeKit will receive the state user has set to before it becomes "set". + * + * @since 1.0.0 + */ switch (true) { - case mode === 'current' && panelStatuses.includes('BURGLARY ALARM'): - case mode === 'current' && panelStatuses.includes('Carbon Monoxide Alarm'): - case mode === 'current' && panelStatuses.includes('FIRE ALARM'): - case mode === 'current' && panelStatuses.includes('Uncleared Alarm'): - case mode === 'current' && panelStatuses.includes('WATER ALARM'): - // TODO: Try to test Smoke or CO alarm while status is Disarmed and see if I'm able to disarm the system in Home app. - // TODO: If I cannot disarm, then I can only include BURGLARY ALARM in here. + case mode === 'current' && isPanelAlarmActive(panelStatuses): return this.#characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED; - case mode === 'current' && panelStates.includes('Armed Away'): - return this.#characteristic.SecuritySystemCurrentState.AWAY_ARM; + case mode === 'current' && this.#status.isBusy && this.#status.setValue !== null: + return this.#status.setValue; case mode === 'current' && panelStates.includes('Armed Stay'): return this.#characteristic.SecuritySystemCurrentState.STAY_ARM; + case mode === 'current' && panelStates.includes('Armed Away'): + return this.#characteristic.SecuritySystemCurrentState.AWAY_ARM; case mode === 'current' && panelStates.includes('Armed Night'): return this.#characteristic.SecuritySystemCurrentState.NIGHT_ARM; case mode === 'current' && panelStates.includes('Disarmed'): @@ -808,12 +636,24 @@ export class ADTPulseAccessory { break; } - // Find the target state for the panel (required characteristic). + /** + * // Find the target state for the panel (required characteristic). + * + * Notes: + * - If system is in an alarm state, HomeKit will receive a "STAY_ARM" state so the user can disarm from Home app. + * - If system is busy setting the state, HomeKit will receive the state user has set to before it becomes "set". + * + * @since 1.0.0 + */ switch (true) { - case mode === 'target' && panelStates.includes('Armed Away'): - return this.#characteristic.SecuritySystemTargetState.AWAY_ARM; + case mode === 'target' && panelStates.includes('Disarmed') && isPanelAlarmActive(panelStatuses): + return this.#characteristic.SecuritySystemTargetState.STAY_ARM; + case mode === 'target' && this.#status.isBusy && this.#status.setValue !== null: + return this.#status.setValue; case mode === 'target' && panelStates.includes('Armed Stay'): return this.#characteristic.SecuritySystemTargetState.STAY_ARM; + case mode === 'target' && panelStates.includes('Armed Away'): + return this.#characteristic.SecuritySystemTargetState.AWAY_ARM; case mode === 'target' && panelStates.includes('Armed Night'): return this.#characteristic.SecuritySystemTargetState.NIGHT_ARM; case mode === 'target' && panelStates.includes('Disarmed'): @@ -828,26 +668,15 @@ export class ADTPulseAccessory { this.#log.warn(`Attempted to get panel status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but panel state is "Status Unavailable".`); - // The error message is either thrown or returned depending on the caller. - switch (caller) { - case 'updater': - return hapStatus as ADTPulseAccessoryGetSensorStatusReturns; - default: - throw hapStatus; - } + return hapStatus; } + // Attempted to get panel status, but actions have not been implemented yet. hapStatus = new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.SERVICE_COMMUNICATION_FAILURE); this.#log.warn(`Attempted to get panel status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but actions have not been implemented yet.`); - // The error message is either thrown or returned depending on the caller. - switch (caller) { - case 'updater': - return hapStatus as ADTPulseAccessoryGetSensorStatusReturns; - default: - throw hapStatus; - } + return hapStatus; } /** @@ -870,6 +699,7 @@ export class ADTPulseAccessory { uuid, } = context; + let hapStatus; let result = { success: false, }; @@ -877,62 +707,95 @@ export class ADTPulseAccessory { // If device is not a security panel. if (type !== 'panel') { + hapStatus = new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST); + this.#log.error(`Attempted to set panel status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but device is not a security panel.`); - throw new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST); + throw hapStatus; } // If panel status has not been retrieved yet. if (this.#state.data.panelStatus === null || this.#state.data.panelStatus.panelStates.length === 0) { + hapStatus = new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.RESOURCE_BUSY); + this.#log.warn(`Attempted to set panel status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but panel status has not been retrieved yet.`); - throw new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.RESOURCE_BUSY); + throw hapStatus; } const { panelStates } = this.#state.data.panelStatus; - const armFrom = condensePanelStates(panelStates); + const armFrom = condensePanelStates(this.#characteristic, panelStates); + const isAlarmActive = isPanelAlarmActive(this.#state.data.panelStatus.panelStatuses); // If panel status cannot be found or most likely "Status Unavailable". if (armFrom === undefined) { + hapStatus = new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.RESOURCE_BUSY); + this.#log.warn(`Attempted to set panel status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but panel status cannot be found or most likely "Status Unavailable".`); - throw new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.RESOURCE_BUSY); + throw hapStatus; } - // Set the panel status. - switch (arm) { - case this.#characteristic.SecuritySystemTargetState.STAY_ARM: - result = await this.#instance.setPanelStatus(armFrom, 'stay'); - break; - case this.#characteristic.SecuritySystemTargetState.AWAY_ARM: - result = await this.#instance.setPanelStatus(armFrom, 'away'); - break; - case this.#characteristic.SecuritySystemTargetState.NIGHT_ARM: - result = await this.#instance.setPanelStatus(armFrom, 'night'); - break; - case this.#characteristic.SecuritySystemTargetState.DISARM: - result = await this.#instance.setPanelStatus(armFrom, 'off'); - break; - default: - unknownArmValue = true; - break; - } + // If user is not setting to the current arm state (e.g. off to off) or if trying to turn off alarm. + if ( + armFrom.characteristicValue !== arm + || ( + armFrom.armValue === 'off' + && arm === this.#characteristic.SecuritySystemTargetState.DISARM + && isAlarmActive + ) + ) { + // Set this accessory status to "busy" before arming. For "panelCurrentValue" + this.#status = { + isBusy: true, + setValue: arm, + }; + + // Set the panel status. + switch (arm) { + case this.#characteristic.SecuritySystemTargetState.STAY_ARM: + result = await this.#instance.setPanelStatus(armFrom.armValue, 'stay', isAlarmActive); + break; + case this.#characteristic.SecuritySystemTargetState.AWAY_ARM: + result = await this.#instance.setPanelStatus(armFrom.armValue, 'away', isAlarmActive); + break; + case this.#characteristic.SecuritySystemTargetState.NIGHT_ARM: + result = await this.#instance.setPanelStatus(armFrom.armValue, 'night', isAlarmActive); + break; + case this.#characteristic.SecuritySystemTargetState.DISARM: + result = await this.#instance.setPanelStatus(armFrom.armValue, 'off', isAlarmActive); + break; + default: + unknownArmValue = true; + break; + } - // If request has unknown arm value. - if (unknownArmValue) { - this.#log.error(`Attempted to set panel status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but request has unknown arm value.`); + // Set this accessory status to "idle" after arming (6 to 9 seconds). + this.#status = { + isBusy: false, + setValue: null, + }; - throw new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST); - } + // If request has unknown arm value. + if (unknownArmValue) { + hapStatus = new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.INVALID_VALUE_IN_REQUEST); + + this.#log.error(`Attempted to set panel status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but request has unknown arm value.`); - // If request was not successful. - if (!result.success) { - this.#log.error(`Attempted to set panel status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but request was not successful.`); + throw hapStatus; + } + + // If request was not successful. + if (!result.success) { + hapStatus = new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.OPERATION_TIMED_OUT); - stackTracer('api-response', result); + this.#log.error(`Attempted to set panel status on ${chalk.underline(name)} (id: ${id}, uuid: ${uuid}) accessory but request was not successful.`); - throw new this.#api.hap.HapStatusError(this.#api.hap.HAPStatus.OPERATION_TIMED_OUT); + stackTracer('api-response', result); + + throw hapStatus; + } } } } diff --git a/src/lib/api.ts b/src/lib/api.ts index 06a0bb8..dc1321b 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -39,6 +39,7 @@ import { fetchTableCells, findNullKeys, generateDynatracePCHeaderValue, + generateFakeReadyButtons, generateHash, isPortalSyncCode, parseArmDisarmMessage, @@ -54,6 +55,7 @@ import type { ADTPulseArmDisarmHandlerArm, ADTPulseArmDisarmHandlerArmState, ADTPulseArmDisarmHandlerHref, + ADTPulseArmDisarmHandlerIsAlarmActive, ADTPulseArmDisarmHandlerReadyButton, ADTPulseArmDisarmHandlerRelativeUrl, ADTPulseArmDisarmHandlerReturns, @@ -103,6 +105,7 @@ import type { ADTPulseSession, ADTPulseSetPanelStatusArmFrom, ADTPulseSetPanelStatusArmTo, + ADTPulseSetPanelStatusIsAlarmActive, ADTPulseSetPanelStatusReadyButton, ADTPulseSetPanelStatusReturns, ADTPulseSetPanelStatusSessions, @@ -168,7 +171,7 @@ export class ADTPulse { enabled: internalConfig.testMode?.enabled ?? false, isSystemDisarmedBeforeTest: internalConfig.testMode?.isSystemDisarmedBeforeTest ?? false, }, - waitTimeAfterArm: 6000, // 6 seconds. + waitTimeAfterArm: 5000, // 5 seconds. }; // Set session information to defaults. @@ -189,16 +192,16 @@ export class ADTPulse { debugLog(this.#internal.logger, 'api.ts / ADTPulse.constructor()', 'warn', `Plugin is now running under ${config.speed}x operational speed. You may see slower device updates`); } - // Should be flat rate to prevent excessive waiting. + // Should be statically calculated to prevent excessive waiting. switch (config.speed) { case 0.75: - this.#internal.waitTimeAfterArm = 7000; // 7 seconds. + this.#internal.waitTimeAfterArm = 6000; // 6 seconds. break; case 0.5: - this.#internal.waitTimeAfterArm = 8000; // 8 seconds. + this.#internal.waitTimeAfterArm = 7000; // 7 seconds. break; case 0.25: - this.#internal.waitTimeAfterArm = 9000; // 9 seconds. + this.#internal.waitTimeAfterArm = 8000; // 8 seconds. break; default: break; @@ -1307,14 +1310,15 @@ export class ADTPulse { /** * ADT Pulse - Set panel status. * - * @param {ADTPulseSetPanelStatusArmFrom} armFrom - Arm from. - * @param {ADTPulseSetPanelStatusArmTo} armTo - Arm to. + * @param {ADTPulseSetPanelStatusArmFrom} armFrom - Arm from. + * @param {ADTPulseSetPanelStatusArmTo} armTo - Arm to. + * @param {ADTPulseSetPanelStatusIsAlarmActive} isAlarmActive - Is alarm active. * * @returns {ADTPulseSetPanelStatusReturns} * * @since 1.0.0 */ - public async setPanelStatus(armFrom: ADTPulseSetPanelStatusArmFrom, armTo: ADTPulseSetPanelStatusArmTo): ADTPulseSetPanelStatusReturns { + public async setPanelStatus(armFrom: ADTPulseSetPanelStatusArmFrom, armTo: ADTPulseSetPanelStatusArmTo, isAlarmActive: ADTPulseSetPanelStatusIsAlarmActive): ADTPulseSetPanelStatusReturns { let errorObject; if (this.#internal.debug) { @@ -1351,8 +1355,36 @@ export class ADTPulse { }; } - // If system is being set to the current arm state (e.g. disarmed to off). - if (armFrom === armTo) { + if (typeof isAlarmActive !== 'boolean') { + return { + action: 'SET_PANEL_STATUS', + success: false, + info: { + message: 'You must specify if the system\'s alarm is currently sounding (true) or not (false)', + }, + }; + } + + // If system is being set to the current arm state (e.g. off to off) and alarm is not active. + if ( + ( + armFrom === 'away' + && armTo === 'away' + ) + || ( + armFrom === 'night' + && armTo === 'night' + ) + || ( + armFrom === 'stay' + && armTo === 'stay' + ) + || ( + armFrom === 'off' + && armTo === 'off' + && !isAlarmActive + ) + ) { if (this.#internal.debug) { debugLog(this.#internal.logger, 'api.ts / ADTPulse.setPanelStatus()', 'info', `No need to change arm state from "${armFrom}" to "${armTo}" due to its equivalence`); } @@ -1369,6 +1401,8 @@ export class ADTPulse { try { const sessions: ADTPulseSetPanelStatusSessions = {}; + let isAlarmStillActive = isAlarmActive; + // sessions.axiosSummary: Load the summary page. sessions.axiosSummary = await this.#session.httpClient.get( `${this.#internal.baseUrl}/myhome/${this.#session.portalVersion}/summary/summary.jsp`, @@ -1505,7 +1539,7 @@ export class ADTPulse { * - If "armState" is not "off" or "disarmed", you must disarm first before setting to other modes. * * Footnotes: - * ¹ States are synced across an entire site (per home). If one account arms, every user signed in during that phase becomes "dirty" + * ¹ States are synced across an entire site (per home). If one account arms, every user signed in during that phase becomes "dirty". * ² Turning off siren means system is in "Uncleared Alarm" mode, not truly "Disarmed" mode. * * @since 1.0.0 @@ -1556,44 +1590,41 @@ export class ADTPulse { */ await this.newInformationDispatcher('orb-security-buttons', parsedOrbSecurityButtons); - // WORKAROUND: For arm night button bug - Find the "Arming Night" button location. - const armingNightButtonIndex = parsedOrbSecurityButtons.findIndex((parsedOrbSecurityButton) => { - const parsedOrbSecurityButtonButtonDisabled = parsedOrbSecurityButton.buttonDisabled; - const parsedOrbSecurityButtonButtonText = parsedOrbSecurityButton.buttonText; + // Only keep all ready (enabled) orb security buttons. + let readyButtons = parsedOrbSecurityButtons.filter((parsedOrbSecurityButton): parsedOrbSecurityButton is ADTPulseSetPanelStatusReadyButton => !parsedOrbSecurityButton.buttonDisabled); - return (parsedOrbSecurityButtonButtonDisabled && parsedOrbSecurityButtonButtonText === 'Arming Night'); - }); + // Generate "fake" ready buttons if arming tasks become stuck (backup sat code required). + if (readyButtons.length === 0 && this.#session.backupSatCode !== null) { + readyButtons = generateFakeReadyButtons(parsedOrbSecurityButtons, this.#session.isCleanState, { + relativeUrl: 'quickcontrol/armDisarm.jsp', + href: 'rest/adt/ui/client/security/setArmState', + sat: this.#session.backupSatCode, + }); - // WORKAROUND: For arm night button bug - Replace the "Arming Night" button with a fake "Disarm" button. - if ( - this.#session.backupSatCode !== null // Backup sat code must be available. - && armingNightButtonIndex >= 0 // Make sure that the pending "Arming Night" button is there. - ) { if (this.#internal.debug) { - debugLog(this.#internal.logger, 'api.ts / ADTPulse.setPanelStatus()', 'warn', 'Replacing the stuck "Arming Night" button with a fake "Disarm" button'); + debugLog(this.#internal.logger, 'api.ts / ADTPulse.setPanelStatus()', 'warn', 'No security buttons were found. Replacing stuck orb security buttons with fake buttons'); + stackTracer('fake-ready-buttons', { + before: parsedOrbSecurityButtons, + after: readyButtons, + }); } + } - parsedOrbSecurityButtons[armingNightButtonIndex] = { - buttonDisabled: false, - buttonId: 'security_button_0', - buttonIndex: 0, - buttonText: 'Disarm', - changeAccessCode: false, - loadingText: 'Disarming', - relativeUrl: 'quickcontrol/armDisarm.jsp', - totalButtons: 1, - urlParams: { - arm: 'off', - armState: (this.#session.isCleanState) ? 'night' : 'night+stay', - href: 'rest/adt/ui/client/security/setArmState', - sat: this.#session.backupSatCode, + // If arming tasks become stuck, but no backup sat code was available, return an error. + if (readyButtons.length === 0 && this.#session.backupSatCode === null) { + if (this.#internal.debug) { + debugLog(this.#internal.logger, 'api.ts / ADTPulse.setPanelStatus()', 'error', 'No security buttons were found and replacement failed'); + } + + return { + action: 'SET_PANEL_STATUS', + success: false, + info: { + message: 'No security buttons were found and replacement failed', }, }; } - // Only keep all ready (enabled) orb security buttons. - let readyButtons = parsedOrbSecurityButtons.filter((parsedOrbSecurityButton): parsedOrbSecurityButton is ADTPulseSetPanelStatusReadyButton => !parsedOrbSecurityButton.buttonDisabled); - // Make sure there is at least 1 security button available. if (readyButtons.length < 1) { if (this.#internal.debug) { @@ -1633,8 +1664,8 @@ export class ADTPulse { this.#internal.testMode.isSystemDisarmedBeforeTest = true; } - // If current arm state is not truly "disarmed", disarm it first. - while (!['off', 'disarmed'].includes(readyButtons[0].urlParams.armState)) { + // If current arm state is not truly "disarmed" or alarm is still active, disarm it first. + while (isAlarmStillActive || !['off', 'disarmed'].includes(readyButtons[0].urlParams.armState)) { // Accessing index 0 is guaranteed, because of the check above. const armDisarmResponse = await this.armDisarmHandler( readyButtons[0].relativeUrl, @@ -1642,6 +1673,7 @@ export class ADTPulse { readyButtons[0].urlParams.armState, 'off', readyButtons[0].urlParams.sat, + isAlarmStillActive, ); if (!armDisarmResponse.success) { @@ -1673,6 +1705,9 @@ export class ADTPulse { // Update the ready buttons to the latest known state. readyButtons = armDisarmResponse.info.readyButtons; + + // At this point, the alarm should stop ringing, and state should be "Uncleared Alarm". + isAlarmStillActive = false; } // Track if force arming was required. @@ -1687,6 +1722,7 @@ export class ADTPulse { readyButtons[0].urlParams.armState, armTo, readyButtons[0].urlParams.sat, + false, // Alarm should not be active at this point. ); if (!armDisarmResponse.success) { @@ -1705,7 +1741,7 @@ export class ADTPulse { } if (this.#internal.debug) { - debugLog(this.#internal.logger, 'api.ts / ADTPulse.setPanelStatus()', 'success', `Successfully updated panel status to "${armTo}" at "${this.#internal.baseUrl}"`); + debugLog(this.#internal.logger, 'api.ts / ADTPulse.setPanelStatus()', 'success', `Successfully updated panel status from "${armFrom}" to "${armTo}" at "${this.#internal.baseUrl}"`); } return { @@ -1878,6 +1914,7 @@ export class ADTPulse { * 'Motion Sensor' * 'Motion Sensor (Notable Events Only)' * 'Shock Sensor' + * 'System/Supervisory' * 'Temperature Sensor' * 'Water/Flood Sensor' * 'Window Sensor' @@ -2420,14 +2457,35 @@ export class ADTPulse { return this.#session.isAuthenticated; } + /** + * ADT Pulse - Reset session. + * + * @returns {ADTPulseResetSessionReturns} + * + * @since 1.0.0 + */ + public resetSession(): ADTPulseResetSessionReturns { + this.#session = { + backupSatCode: null, + httpClient: wrapper(axios.create({ + jar: new CookieJar(), + })), + isAuthenticated: false, + isCleanState: true, + networkId: null, + portalVersion: null, + }; + } + /** * ADT Pulse - Arm disarm handler. * - * @param {ADTPulseArmDisarmHandlerRelativeUrl} relativeUrl - Relative url. - * @param {ADTPulseArmDisarmHandlerHref} href - Href. - * @param {ADTPulseArmDisarmHandlerArmState} armState - Arm state. - * @param {ADTPulseArmDisarmHandlerArm} arm - Arm. - * @param {ADTPulseArmDisarmHandlerSat} sat - Sat. + * @param {ADTPulseArmDisarmHandlerRelativeUrl} relativeUrl - Relative url. + * @param {ADTPulseArmDisarmHandlerHref} href - Href. + * @param {ADTPulseArmDisarmHandlerArmState} armState - Arm state. + * @param {ADTPulseArmDisarmHandlerArm} arm - Arm. + * @param {ADTPulseArmDisarmHandlerSat} sat - Sat. + * @param {ADTPulseArmDisarmHandlerIsAlarmActive} isAlarmActive - Is alarm active. * * @private * @@ -2435,19 +2493,40 @@ export class ADTPulse { * * @since 1.0.0 */ - private async armDisarmHandler(relativeUrl: ADTPulseArmDisarmHandlerRelativeUrl, href: ADTPulseArmDisarmHandlerHref, armState: ADTPulseArmDisarmHandlerArmState, arm: ADTPulseArmDisarmHandlerArm, sat: ADTPulseArmDisarmHandlerSat): ADTPulseArmDisarmHandlerReturns { + private async armDisarmHandler(relativeUrl: ADTPulseArmDisarmHandlerRelativeUrl, href: ADTPulseArmDisarmHandlerHref, armState: ADTPulseArmDisarmHandlerArmState, arm: ADTPulseArmDisarmHandlerArm, sat: ADTPulseArmDisarmHandlerSat, isAlarmActive: ADTPulseArmDisarmHandlerIsAlarmActive): ADTPulseArmDisarmHandlerReturns { let errorObject; if (this.#internal.debug) { debugLog(this.#internal.logger, 'api.ts / ADTPulse.armDisarmHandler()', 'info', `Attempting to update arm state from "${armState}" to "${arm}" on "${this.#internal.baseUrl}"`); } - // If system is being set to the current arm state (e.g. disarmed to off). + // If system is being set to the current arm state (e.g. disarmed to off) and alarm is not active. if ( - armState === arm + ( + armState === 'away' + && arm === 'away' + ) + || ( + armState === 'night' + && arm === 'night' + ) + || ( + armState === 'night+stay' + && arm === 'night' + ) + || ( + armState === 'stay' + && arm === 'stay' + ) || ( armState === 'disarmed' && arm === 'off' + && !isAlarmActive + ) + || ( + armState === 'off' + && arm === 'off' + && !isAlarmActive ) ) { if (this.#internal.debug) { @@ -2613,7 +2692,7 @@ export class ADTPulse { // After changing any arm state, the "armState" may be different from when you logged into the portal. this.#session.isCleanState = false; - // Allow the security orb buttons to refresh (usually takes around 6 to 9 seconds). + // Allow some time for the security orb buttons to refresh. await sleep(this.#internal.waitTimeAfterArm); // sessions.axiosSummary: Load the summary page. @@ -2752,7 +2831,7 @@ export class ADTPulse { * - If "armState" is not "off" or "disarmed", you must disarm first before setting to other modes. * * Footnotes: - * ¹ States are synced across an entire site (per home). If one account arms, every user signed in during that phase becomes "dirty" + * ¹ States are synced across an entire site (per home). If one account arms, every user signed in during that phase becomes "dirty". * ² Turning off siren means system is in "Uncleared Alarm" mode, not truly "Disarmed" mode. * * @since 1.0.0 @@ -2805,30 +2884,21 @@ export class ADTPulse { let readyButtons = parsedOrbSecurityButtons.filter((parsedOrbSecurityButton): parsedOrbSecurityButton is ADTPulseArmDisarmHandlerReadyButton => !parsedOrbSecurityButton.buttonDisabled); - // WORKAROUND: For arm night button bug - Generate a fake "parsedOrbSecurityButtons" response after system has been set to "night" mode if "Arming Night" is stuck. - if ( - ['disarmed', 'off'].includes(armState) // Checks if state was "disarmed" (dirty) or "off" (clean). - && ['night'].includes(arm) // Checks if system was trying to change to "night" mode. - && readyButtons.length === 0 // Check if there are no ready (enabled) buttons. - ) { - readyButtons = [ - { - buttonDisabled: false, - buttonId: 'security_button_0', - buttonIndex: 0, - buttonText: 'Disarm', - changeAccessCode: false, - loadingText: 'Disarming', - relativeUrl, - totalButtons: 1, - urlParams: { - arm: 'off', - armState: (this.#session.isCleanState) ? 'night' : 'night+stay', - href, - sat, - }, - }, - ]; + // Generate "fake" ready buttons if arming tasks become stuck. + if (readyButtons.length === 0) { + readyButtons = generateFakeReadyButtons(parsedOrbSecurityButtons, this.#session.isCleanState, { + relativeUrl, + href, + sat, + }); + + if (this.#internal.debug) { + debugLog(this.#internal.logger, 'api.ts / ADTPulse.armDisarmHandler()', 'warn', 'No security buttons were found. Replacing stuck orb security buttons with fake buttons'); + stackTracer('fake-ready-buttons', { + before: parsedOrbSecurityButtons, + after: readyButtons, + }); + } } if (this.#internal.debug) { @@ -3365,26 +3435,4 @@ export class ADTPulse { this.resetSession(); } } - - /** - * ADT Pulse - Reset session. - * - * @private - * - * @returns {ADTPulseResetSessionReturns} - * - * @since 1.0.0 - */ - private resetSession(): ADTPulseResetSessionReturns { - this.#session = { - backupSatCode: null, - httpClient: wrapper(axios.create({ - jar: new CookieJar(), - })), - isAuthenticated: false, - isCleanState: true, - networkId: null, - portalVersion: null, - }; - } } diff --git a/src/lib/detect.ts b/src/lib/detect.ts index f7ceb9e..a76210e 100644 --- a/src/lib/detect.ts +++ b/src/lib/detect.ts @@ -61,6 +61,10 @@ import type { DetectedNewSensorsStatusLogger, DetectedNewSensorsStatusReturns, DetectedNewSensorsStatusSensors, + DetectedSensorCountMismatchData, + DetectedSensorCountMismatchDebugMode, + DetectedSensorCountMismatchLogger, + DetectedSensorCountMismatchReturns, DetectedUnknownSensorsActionDebugMode, DetectedUnknownSensorsActionLogger, DetectedUnknownSensorsActionReturns, @@ -134,6 +138,11 @@ export async function detectedNewDoSubmitHandlers(handlers: DetectedNewDoSubmitH // Show content being sent to author. stackTracer('detect-content', cleanedData); + // Reminder for users that keep restarting Homebridge because they see warnings. + if (logger !== null) { + logger.warn('This message will NOT go away by restarting Homebridge. An update MUST become available first. Please be patient, thank you.'); + } + try { await axios.post( getDetectReportUrl(), @@ -218,6 +227,11 @@ export async function detectedNewGatewayInformation(device: DetectedNewGatewayIn // Show content being sent to author. stackTracer('detect-content', cleanedData); + // Reminder for users that keep restarting Homebridge because they see warnings. + if (logger !== null) { + logger.warn('This message will NOT go away by restarting Homebridge. An update MUST become available first. Please be patient, thank you.'); + } + try { await axios.post( getDetectReportUrl(), @@ -324,6 +338,11 @@ export async function detectedNewOrbSecurityButtons(buttons: DetectedNewOrbSecur // Show content being sent to author. stackTracer('detect-content', cleanedData); + // Reminder for users that keep restarting Homebridge because they see warnings. + if (logger !== null) { + logger.warn('This message will NOT go away by restarting Homebridge. An update MUST become available first. Please be patient, thank you.'); + } + try { await axios.post( getDetectReportUrl(), @@ -408,6 +427,11 @@ export async function detectedNewPanelInformation(device: DetectedNewPanelInform // Show content being sent to author. stackTracer('detect-content', cleanedData); + // Reminder for users that keep restarting Homebridge because they see warnings. + if (logger !== null) { + logger.warn('This message will NOT go away by restarting Homebridge. An update MUST become available first. Please be patient, thank you.'); + } + try { await axios.post( getDetectReportUrl(), @@ -492,6 +516,11 @@ export async function detectedNewPanelStatus(summary: DetectedNewPanelStatusSumm // Show content being sent to author. stackTracer('detect-content', cleanedData); + // Reminder for users that keep restarting Homebridge because they see warnings. + if (logger !== null) { + logger.warn('This message will NOT go away by restarting Homebridge. An update MUST become available first. Please be patient, thank you.'); + } + try { await axios.post( getDetectReportUrl(), @@ -576,6 +605,11 @@ export async function detectedNewPortalVersion(version: DetectedNewPortalVersion // Show content being sent to author. stackTracer('detect-content', cleanedData); + // Reminder for users that keep restarting Homebridge because they see warnings. + if (logger !== null) { + logger.warn('This message will NOT go away by restarting Homebridge. An update MUST become available first. Please be patient, thank you.'); + } + try { await axios.post( getDetectReportUrl(), @@ -663,6 +697,11 @@ export async function detectedNewSensorsInformation(sensors: DetectedNewSensorsI // Show content being sent to author. stackTracer('detect-content', cleanedData); + // Reminder for users that keep restarting Homebridge because they see warnings. + if (logger !== null) { + logger.warn('This message will NOT go away by restarting Homebridge. An update MUST become available first. Please be patient, thank you.'); + } + try { await axios.post( getDetectReportUrl(), @@ -747,6 +786,11 @@ export async function detectedNewSensorsStatus(sensors: DetectedNewSensorsStatus // Show content being sent to author. stackTracer('detect-content', cleanedData); + // Reminder for users that keep restarting Homebridge because they see warnings. + if (logger !== null) { + logger.warn('This message will NOT go away by restarting Homebridge. An update MUST become available first. Please be patient, thank you.'); + } + try { await axios.post( getDetectReportUrl(), @@ -775,10 +819,99 @@ export async function detectedNewSensorsStatus(sensors: DetectedNewSensorsStatus return false; } +/** + * Detected sensor count mismatch. + * + * @param {DetectedSensorCountMismatchData} data - Data. + * @param {DetectedSensorCountMismatchLogger} logger - Logger. + * @param {DetectedSensorCountMismatchDebugMode} debugMode - Debug mode. + * + * @returns {DetectedSensorCountMismatchReturns} + * + * @since 1.0.0 + */ +export async function detectedSensorCountMismatch(data: DetectedSensorCountMismatchData, logger: DetectedSensorCountMismatchLogger, debugMode: DetectedSensorCountMismatchDebugMode): DetectedSensorCountMismatchReturns { + const detectedCountMismatch = data.sensorsInfo.length !== data.sensorsStatus.length; + + if (detectedCountMismatch) { + const cleanedData = removePersonalIdentifiableInformation(data); + + // If outdated, it means plugin may already have support. + try { + const outdated = await isPluginOutdated(); + + if (outdated) { + if (logger !== null) { + logger.warn('Plugin has detected a sensor count mismatch. You are running an older plugin version, please update soon.'); + } + + // This is intentionally duplicated if using Homebridge debug mode. + if (debugMode) { + debugLog(logger, 'detect.ts / detectedSensorCountMismatch()', 'warn', 'Plugin has detected a sensor count mismatch. You are running an older plugin version, please update soon'); + } + + // Do not send analytics for users running outdated plugin versions. + return false; + } + } catch (error) { + if (debugMode === true) { + debugLog(logger, 'detect.ts / detectedSensorCountMismatch()', 'error', 'Failed to check if plugin is outdated'); + stackTracer('serialize-error', serializeError(error)); + } + + // Try to check if plugin is outdated later on. + return false; + } + + if (logger !== null) { + logger.warn('Plugin has detected a sensor count mismatch. Notifying plugin author about this discovery ...'); + } + + // This is intentionally duplicated if using Homebridge debug mode. + if (debugMode) { + debugLog(logger, 'detect.ts / detectedSensorCountMismatch()', 'warn', 'Plugin has detected a sensor count mismatch. Notifying plugin author about this discovery'); + } + + // Show content being sent to author. + stackTracer('detect-content', cleanedData); + + // Reminder for users that keep restarting Homebridge because they see warnings. + if (logger !== null) { + logger.warn('This message will NOT go away by restarting Homebridge. An update MUST become available first. Please be patient, thank you.'); + } + + try { + await axios.post( + getDetectReportUrl(), + JSON.stringify(cleanedData, null, 2), + { + family: 4, + headers: { + 'User-Agent': 'homebridge-adt-pulse', + 'X-Title': 'Detected a sensor count mismatch', + }, + }, + ); + + return true; + } catch (error) { + if (debugMode === true) { + debugLog(logger, 'detect.ts / detectedSensorCountMismatch()', 'error', 'Failed to notify plugin author about the sensor count mismatch'); + stackTracer('serialize-error', serializeError(error)); + } + + // Try to send information to author later. + return false; + } + } + + return false; +} + /** * Detected unknown sensors action. * - * @param {DetectedUnknownSensorsActionSensors} sensors - Sensors + * @param {DetectedUnknownSensorsActionSensors} sensors - Sensors. * @param {DetectedUnknownSensorsActionLogger} logger - Logger. * @param {DetectedUnknownSensorsActionDebugMode} debugMode - Debug mode. * @@ -845,6 +978,11 @@ export async function detectedUnknownSensorsAction(sensors: DetectedUnknownSenso // Show content being sent to author. stackTracer('detect-content', cleanedData); + // Reminder for users that keep restarting Homebridge because they see warnings. + if (logger !== null) { + logger.warn('This message will NOT go away by restarting Homebridge. An update MUST become available first. Please be patient, thank you.'); + } + try { await axios.post( getDetectReportUrl(), diff --git a/src/lib/items.ts b/src/lib/items.ts index be7e080..803b5a9 100644 --- a/src/lib/items.ts +++ b/src/lib/items.ts @@ -40,6 +40,7 @@ export const condensedSensorTypeItems: CondensedSensorTypeItems = [ 'heat', 'motion', 'shock', + 'supervisory', 'temperature', ]; @@ -274,16 +275,25 @@ export const sensorActionItems: SensorActionItems = [ { type: 'doorWindow', statuses: [ + 'ALARM, Closed', + 'ALARM, Open', 'Bypassed, Closed', 'Bypassed, Open', 'Closed', + 'Low Battery, Closed', + 'Low Battery, Open', 'Open', + 'Trouble, Closed', + 'Trouble, Open', 'Unknown', ], }, { type: 'fire', statuses: [ + 'ALARM, Okay', + 'ALARM, Tripped', + 'Low Battery, Okay', 'Okay', 'Unknown', ], @@ -297,7 +307,9 @@ export const sensorActionItems: SensorActionItems = [ { type: 'glass', statuses: [ + 'ALARM, Okay', 'Okay', + 'Tripped', 'Unknown', ], }, @@ -310,6 +322,7 @@ export const sensorActionItems: SensorActionItems = [ { type: 'motion', statuses: [ + 'ALARM, Motion', 'Motion', 'No Motion', 'Okay', @@ -322,9 +335,17 @@ export const sensorActionItems: SensorActionItems = [ '', ], }, + { + type: 'supervisory', + statuses: [ + '', + ], + }, { type: 'temperature', statuses: [ + 'ALARM, Okay', + 'ALARM, Tripped', 'Okay', ], }, @@ -345,6 +366,7 @@ export const sensorInformationDeviceTypeItems: SensorInformationDeviceTypeItems 'Motion Sensor', 'Motion Sensor (Notable Events Only)', 'Shock Sensor', + 'System/Supervisory', 'Temperature Sensor', 'Water/Flood Sensor', 'Window Sensor', diff --git a/src/lib/platform.ts b/src/lib/platform.ts index c3ca661..805f917 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -1,4 +1,5 @@ import chalk from 'chalk'; +import _ from 'lodash'; import { arch, argv, @@ -9,7 +10,8 @@ import { serializeError } from 'serialize-error'; import { ADTPulseAccessory } from '@/lib/accessory.js'; import { ADTPulse } from '@/lib/api.js'; -import { detectedUnknownSensorsAction } from '@/lib/detect.js'; +import { detectedSensorCountMismatch, detectedUnknownSensorsAction } from '@/lib/detect.js'; +import { textOrbTextSummarySections } from '@/lib/regex.js'; import { platformConfig } from '@/lib/schema.js'; import { condenseSensorType, @@ -40,6 +42,9 @@ import type { ADTPulsePlatformHandlers, ADTPulsePlatformInstance, ADTPulsePlatformLog, + ADTPulsePlatformLogStatusChangesNewCache, + ADTPulsePlatformLogStatusChangesOldCache, + ADTPulsePlatformLogStatusChangesReturns, ADTPulsePlatformPlugin, ADTPulsePlatformPollAccessoriesDevices, ADTPulsePlatformPollAccessoriesReturns, @@ -181,7 +186,8 @@ export class ADTPulsePlatform implements ADTPulsePlatformPlugin { this.#config = null; this.#constants = { intervalTimestamps: { - adtKeepAlive: 538000, // 8.9666666667 minutes. + adtKeepAlive: 538000, // 8 minutes, 58 seconds. + adtSessionMax: 19368000, // 5 hours, 22 minutes, 48 seconds. adtSyncCheck: 3000, // 3 seconds. suspendSyncing: 1800000, // 30 minutes. synchronize: 1000, // 1 second. @@ -216,6 +222,7 @@ export class ADTPulsePlatform implements ADTPulsePlatformPlugin { }, lastRunOn: { adtKeepAlive: 0, // January 1, 1970, at 00:00:00 UTC. + adtLastLogin: 0, // January 1, 1970, at 00:00:00 UTC. adtSyncCheck: 0, // January 1, 1970, at 00:00:00 UTC. }, reportedHashes: [], @@ -541,9 +548,16 @@ export class ADTPulsePlatform implements ADTPulsePlatformPlugin { // Attempt to synchronize. try { + let currentTimestamp = Date.now(); + // ACTIVITY: Start sync. this.#state.activity.isSyncing = true; + // If login session has become stale, force a session reset. + if ((currentTimestamp - this.#state.lastRunOn.adtLastLogin) >= this.#constants.intervalTimestamps.adtSessionMax) { + this.#instance.resetSession(); + } + // Perform login action if "this instance" is not authenticated. if (!this.#instance.isAuthenticated()) { // Attempt to log in if "this instance" is not currently logging in. @@ -555,10 +569,11 @@ export class ADTPulsePlatform implements ADTPulsePlatformPlugin { // If login was successful. if (login.success) { - const currentTimestamp = Date.now(); + currentTimestamp = Date.now(); // Update timing for the sync protocols, so they can pace themselves. this.#state.lastRunOn.adtKeepAlive = currentTimestamp; + this.#state.lastRunOn.adtLastLogin = currentTimestamp; this.#state.lastRunOn.adtSyncCheck = currentTimestamp; } @@ -600,7 +615,7 @@ export class ADTPulsePlatform implements ADTPulsePlatformPlugin { } // Get the current timestamp. - const currentTimestamp = Date.now(); + currentTimestamp = Date.now(); // Run the keep alive request if time has reached. Do not await, they shall run at their own pace. if (currentTimestamp - this.#state.lastRunOn.adtKeepAlive >= this.#constants.intervalTimestamps.adtKeepAlive) { @@ -776,6 +791,8 @@ export class ADTPulsePlatform implements ADTPulsePlatformPlugin { return; } + const cachedState = _.clone(this.#state.data); + try { // Fetch all the panel and sensor information. const requests = await Promise.all([ @@ -826,6 +843,9 @@ export class ADTPulsePlatform implements ADTPulsePlatformPlugin { this.#state.data.sensorsStatus = sensors; } + // Check if device statuses have changed. + await this.logStatusChanges(cachedState, this.#state.data); + // Check for unknown sensor actions. await this.unknownInformationDispatcher(); @@ -837,6 +857,106 @@ export class ADTPulsePlatform implements ADTPulsePlatformPlugin { } } + /** + * ADT Pulse Platform - Log status changes. + * + * @param {ADTPulsePlatformLogStatusChangesOldCache} oldCache - Old cache. + * @param {ADTPulsePlatformLogStatusChangesNewCache} newCache - New cache. + * + * @private + * + * @returns {ADTPulsePlatformLogStatusChangesReturns} + * + * @since 1.0.0 + */ + private async logStatusChanges(oldCache: ADTPulsePlatformLogStatusChangesOldCache, newCache: ADTPulsePlatformLogStatusChangesNewCache): ADTPulsePlatformLogStatusChangesReturns { + // Fetch gateway device status. + 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}").`); + } + } + + // Fetch the panel device status. + 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}").`); + } + } + + // Fetch the sensors device status. + 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 device status cannot be determined due to length inconsistencies.'); + stackTracer('log-status-changes', { + old: oldCache.sensorsInfo, + new: newCache.sensorsInfo, + }); + } + } + + // Fetch the panel device state. + 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}").`); + } + } + + // Fetch the sensors device state. + 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 device state cannot be determined due to length inconsistencies.'); + stackTracer('log-status-changes', { + old: oldCache.sensorsStatus, + new: newCache.sensorsStatus, + }); + } + } + } + /** * ADT Pulse Platform - Unknown information dispatcher. * @@ -851,16 +971,27 @@ export class ADTPulsePlatform implements ADTPulsePlatformPlugin { // Check if there was a mismatch between the "sensorsInfo" and "sensorsStatus" array. if (sensorsInfo.length !== sensorsStatus.length) { - this.#log.error('It seems like there is a mis-match between the sensors information and sensors status. This should not be happening.'); - stackTracer('sensor-mismatch', { + const data = { sensorsInfo, sensorsStatus, - }); + }; + const dataHash = generateHash(`${JSON.stringify(data)}`); + + // If the detector has not reported this event before. + if (this.#state.reportedHashes.find((reportedHash) => dataHash === reportedHash) === undefined) { + const detectedNew = await detectedSensorCountMismatch(data, this.#log, this.#debugMode); + + // Save this hash so the detector does not detect the same thing multiple times. + if (detectedNew) { + this.#state.reportedHashes.push(dataHash); + } + } // Stop here until this is resolved. return; } + // Generate an array of matching "sensorInfo" and "sensorStatus" with the device type. const sensors = sensorsInfo.map((sensorInfo, sensorsInfoKey) => ({ info: sensorInfo, status: sensorsStatus[sensorsInfoKey], diff --git a/src/lib/schema.ts b/src/lib/schema.ts index 7e24314..7a0fd6e 100644 --- a/src/lib/schema.ts +++ b/src/lib/schema.ts @@ -38,6 +38,7 @@ export const platformConfig = z.object({ z.literal('heat'), z.literal('motion'), z.literal('shock'), + z.literal('supervisory'), z.literal('temperature'), ]), adtZone: z.number().min(1).max(999), diff --git a/src/lib/utility.ts b/src/lib/utility.ts index b348132..34969ec 100644 --- a/src/lib/utility.ts +++ b/src/lib/utility.ts @@ -26,6 +26,7 @@ import type { ClearHtmlLineBreakReturns, ClearWhitespaceData, ClearWhitespaceReturns, + CondensePanelStatesCharacteristic, CondensePanelStatesCondensed, CondensePanelStatesPanelStates, CondensePanelStatesReturns, @@ -56,6 +57,12 @@ import type { FindNullKeysReturns, GenerateDynatracePCHeaderValueMode, GenerateDynatracePCHeaderValueReturns, + GenerateFakeReadyButtonsButtons, + GenerateFakeReadyButtonsDisplayedButtons, + GenerateFakeReadyButtonsIsCleanState, + GenerateFakeReadyButtonsOptions, + GenerateFakeReadyButtonsReadyButtons, + GenerateFakeReadyButtonsReturns, GenerateHashData, GenerateHashReturns, GetAccessoryCategoryDeviceCategory, @@ -67,6 +74,8 @@ import type { GetPluralFormReturns, GetPluralFormSingular, IsForwardSlashOSReturns, + IsPanelAlarmActivePanelStatuses, + IsPanelAlarmActiveReturns, IsPluginOutdatedReturns, IsPortalSyncCodeSyncCode, IsPortalSyncCodeVerifiedSyncCode, @@ -147,28 +156,41 @@ export function clearWhitespace(data: ClearWhitespaceData): ClearWhitespaceRetur /** * Condense panel states. * - * @param {CondensePanelStatesPanelStates} panelStates - Panel states. + * @param {CondensePanelStatesCharacteristic} characteristic - Characteristic. + * @param {CondensePanelStatesPanelStates} panelStates - Panel states. * * @returns {CondensePanelStatesReturns} * * @since 1.0.0 */ -export function condensePanelStates(panelStates: CondensePanelStatesPanelStates): CondensePanelStatesReturns { +export function condensePanelStates(characteristic: CondensePanelStatesCharacteristic, panelStates: CondensePanelStatesPanelStates): CondensePanelStatesReturns { let condensed: CondensePanelStatesCondensed; // Only detect panel states used for arming/disarming system. switch (true) { case panelStates.includes('Armed Away'): - condensed = 'away'; + condensed = { + armValue: 'away', + characteristicValue: characteristic.SecuritySystemTargetState.AWAY_ARM, + }; break; case panelStates.includes('Armed Night'): - condensed = 'night'; + condensed = { + armValue: 'night', + characteristicValue: characteristic.SecuritySystemTargetState.NIGHT_ARM, + }; break; case panelStates.includes('Armed Stay'): - condensed = 'stay'; + condensed = { + armValue: 'stay', + characteristicValue: characteristic.SecuritySystemTargetState.STAY_ARM, + }; break; case panelStates.includes('Disarmed'): - condensed = 'off'; + condensed = { + armValue: 'off', + characteristicValue: characteristic.SecuritySystemTargetState.DISARM, + }; break; default: break; @@ -218,6 +240,9 @@ export function condenseSensorType(sensorType: CondenseSensorTypeSensorType): Co case 'Shock Sensor': condensed = 'shock'; break; + case 'System/Supervisory': + condensed = 'supervisory'; + break; case 'Temperature Sensor': condensed = 'temperature'; break; @@ -525,6 +550,133 @@ export function generateDynatracePCHeaderValue(mode: GenerateDynatracePCHeaderVa return `${serverId}$${slicedMillis}_${randomThreeDigit}h${randomTwoDigit}v${randomAlphabet}-0e0`; } +/** + * Generate fake ready buttons. + * + * @param {GenerateFakeReadyButtonsButtons} buttons - Buttons. + * @param {GenerateFakeReadyButtonsIsCleanState} isCleanState - Is clean state. + * @param {GenerateFakeReadyButtonsOptions} options - Options. + * + * @returns {GenerateFakeReadyButtonsReturns} + * + * @since 1.0.0 + */ +export function generateFakeReadyButtons(buttons: GenerateFakeReadyButtonsButtons, isCleanState: GenerateFakeReadyButtonsIsCleanState, options: GenerateFakeReadyButtonsOptions): GenerateFakeReadyButtonsReturns { + const readyButtons: GenerateFakeReadyButtonsReadyButtons = []; + + // If the button is a "Disarming" button, generate the three arm buttons. + if (buttons.length === 1 && buttons[0].buttonText === 'Disarming') { + const displayedButtons: GenerateFakeReadyButtonsDisplayedButtons = [ + { + buttonText: 'Arm Away', + loadingText: 'Arming Away', + arm: 'away', + }, + { + buttonText: 'Arm Stay', + loadingText: 'Arming Stay', + arm: 'stay', + }, + { + buttonText: 'Arm Night', + loadingText: 'Arming Night', + arm: 'night', + }, + ]; + + displayedButtons.forEach((displayedButton, displayedButtonIndex) => { + readyButtons.push({ + buttonId: `security_button_${displayedButtonIndex}`, + buttonDisabled: false, + buttonIndex: displayedButtonIndex, + buttonText: displayedButton.buttonText, + changeAccessCode: false, + loadingText: displayedButton.loadingText, + relativeUrl: options.relativeUrl, + totalButtons: buttons.length, + urlParams: { + arm: displayedButton.arm, + armState: (isCleanState) ? 'off' : 'disarmed', + href: options.href, + sat: options.sat, + }, + }); + }); + + return readyButtons; + } + + buttons.forEach((button, buttonIndex) => { + // If button is already a "ready button", copy the button and skip processing. + if (!button.buttonDisabled) { + readyButtons.push(button); + + return; + } + + switch (button.buttonText) { + case 'Arming Away': + readyButtons.push({ + buttonId: button.buttonId, + buttonDisabled: false, + buttonIndex, + buttonText: 'Disarm', + changeAccessCode: false, + loadingText: 'Disarming', + relativeUrl: options.relativeUrl, + totalButtons: buttons.length, + urlParams: { + arm: 'off', + armState: (isCleanState) ? 'away' : 'away', + href: options.href, + sat: options.sat, + }, + }); + break; + case 'Arming Night': + readyButtons.push({ + buttonId: button.buttonId, + buttonDisabled: false, + buttonIndex, + buttonText: 'Disarm', + changeAccessCode: false, + loadingText: 'Disarming', + relativeUrl: options.relativeUrl, + totalButtons: buttons.length, + urlParams: { + arm: 'off', + armState: (isCleanState) ? 'night' : 'night+stay', + href: options.href, + sat: options.sat, + }, + }); + break; + case 'Arming Stay': + readyButtons.push({ + buttonId: button.buttonId, + buttonDisabled: false, + buttonIndex, + buttonText: 'Disarm', + changeAccessCode: false, + loadingText: 'Disarming', + relativeUrl: options.relativeUrl, + totalButtons: buttons.length, + urlParams: { + arm: 'off', + armState: (isCleanState) ? 'stay' : 'stay', + href: options.href, + sat: options.sat, + }, + }); + break; + default: + break; + } + }); + + return readyButtons; +} + /** * Generate hash. * @@ -629,6 +781,27 @@ export function isForwardSlashOS(): IsForwardSlashOSReturns { ].includes(currentOS); } +/** + * Is panel alarm active. + * + * @param {IsPanelAlarmActivePanelStatuses} panelStatuses - Panel statuses. + * + * @returns {IsPanelAlarmActiveReturns} + * + * @since 1.0.0 + */ +export function isPanelAlarmActive(panelStatuses: IsPanelAlarmActivePanelStatuses): IsPanelAlarmActiveReturns { + return ( + panelStatuses.includes('BURGLARY ALARM') + || panelStatuses.includes('Carbon Monoxide Alarm') + || panelStatuses.includes('FIRE ALARM') + || panelStatuses.includes('Sensor Problem') + || panelStatuses.includes('Sensor Problems') + || panelStatuses.includes('Uncleared Alarm') + || panelStatuses.includes('WATER ALARM') + ); +} + /** * Is plugin outdated. * @@ -1064,7 +1237,8 @@ export function stackTracer(type: StackTracerType, error: StackTracerError; @@ -461,6 +466,8 @@ export type ADTPulseSetPanelStatusArmFrom = PortalPanelArmValue; export type ADTPulseSetPanelStatusArmTo = PortalPanelArmValue; +export type ADTPulseSetPanelStatusIsAlarmActive = boolean | undefined; + export type ADTPulseSetPanelStatusReturnsInfoForceArmRequired = boolean; export type ADTPulseSetPanelStatusReturnsInfo = { @@ -521,13 +528,9 @@ export type ADTPulseAccessoryConstructorLog = Logger; * * @since 1.0.0 */ -export type ADTPulseAccessoryGetPanelStatusCaller = 'constructor' | 'updater'; - export type ADTPulseAccessoryGetPanelStatusMode = 'alarmType' | 'current' | 'fault' | 'tamper' | 'target'; -export type ADTPulseAccessoryGetPanelStatusReturns = - Caller extends 'updater' ? HapStatusError | Error | Nullable - : Nullable; +export type ADTPulseAccessoryGetPanelStatusReturns = HapStatusError | Error | Nullable; /** * ADT Pulse Accessory - Set panel status. @@ -543,13 +546,9 @@ export type ADTPulseAccessorySetPanelStatusReturns = Promise; * * @since 1.0.0 */ -export type ADTPulseAccessoryGetSensorStatusCaller = 'constructor' | 'updater'; - export type ADTPulseAccessoryGetSensorStatusMode = 'active' | 'fault' | 'lowBattery' | 'status' | 'tamper'; -export type ADTPulseAccessoryGetSensorStatusReturns = - Caller extends 'updater' ? HapStatusError | Error | Nullable - : Nullable; +export type ADTPulseAccessoryGetSensorStatusReturns = HapStatusError | Error | Nullable; /** * ADT Pulse Accessory - Instance. @@ -579,6 +578,20 @@ export type ADTPulseAccessoryServices = Record; */ export type ADTPulseAccessoryState = ADTPulsePlatformState; +/** + * ADT Pulse Accessory - Status. + * + * @since 1.0.0 + */ +export type ADTPulseAccessoryStatusIsBusy = boolean; + +export type ADTPulseAccessoryStatusSetValue = Nullable; + +export type ADTPulseAccessoryStatus = { + isBusy: ADTPulseAccessoryStatusIsBusy; + setValue: ADTPulseAccessoryStatusSetValue; +}; + /** * ADT Pulse Accessory - Updater. * @@ -650,6 +663,8 @@ export type ADTPulsePlatformConfigureAccessoryReturns = void; */ export type ADTPulsePlatformConstantsTimestampsAdtKeepAlive = number; +export type ADTPulsePlatformConstantsTimestampsAdtSessionMax = number; + export type ADTPulsePlatformConstantsTimestampsAdtSyncCheck = number; export type ADTPulsePlatformConstantsTimestampsSuspendSyncing = number; @@ -658,6 +673,7 @@ export type ADTPulsePlatformConstantsTimestampsSynchronize = number; export type ADTPulsePlatformConstantsTimestamps = { adtKeepAlive: ADTPulsePlatformConstantsTimestampsAdtKeepAlive; + adtSessionMax: ADTPulsePlatformConstantsTimestampsAdtSessionMax; adtSyncCheck: ADTPulsePlatformConstantsTimestampsAdtSyncCheck; suspendSyncing: ADTPulsePlatformConstantsTimestampsSuspendSyncing; synchronize: ADTPulsePlatformConstantsTimestampsSynchronize; @@ -716,6 +732,17 @@ export type ADTPulsePlatformInstance = ADTPulse | null; */ export type ADTPulsePlatformLog = Logger; +/** + * ADT Pulse Platform - Log status changes. + * + * @since 1.0.0 + */ +export type ADTPulsePlatformLogStatusChangesOldCache = ADTPulsePlatformStateData; + +export type ADTPulsePlatformLogStatusChangesNewCache = ADTPulsePlatformStateData; + +export type ADTPulsePlatformLogStatusChangesReturns = Promise; + /** * ADT Pulse Platform - Poll accessories. * @@ -776,9 +803,13 @@ export type ADTPulsePlatformStateDataPanelInfo = PanelInformation | null; export type ADTPulsePlatformStateDataPanelStatus = PanelStatus | null; -export type ADTPulsePlatformStateDataSensorsInfo = SensorsInformation; +export type ADTPulsePlatformStateDataSensorInfo = SensorInformation; + +export type ADTPulsePlatformStateDataSensorsInfo = ADTPulsePlatformStateDataSensorInfo[]; -export type ADTPulsePlatformStateDataSensorsStatus = SensorsStatus; +export type ADTPulsePlatformStateDataSensorStatus = SensorStatus; + +export type ADTPulsePlatformStateDataSensorsStatus = ADTPulsePlatformStateDataSensorStatus[]; export type ADTPulsePlatformStateDataSyncCode = PortalSyncCode; @@ -805,10 +836,13 @@ export type ADTPulsePlatformStateIntervals = { export type ADTPulsePlatformStateLastRunOnAdtKeepAlive = number; +export type ADTPulsePlatformStateLastRunOnAdtLastLogin = number; + export type ADTPulsePlatformStateLastRunOnAdtSyncCheck = number; export type ADTPulsePlatformStateLastRunOn = { adtKeepAlive: ADTPulsePlatformStateLastRunOnAdtKeepAlive; + adtLastLogin: ADTPulsePlatformStateLastRunOnAdtLastLogin; adtSyncCheck: ADTPulsePlatformStateLastRunOnAdtSyncCheck; }; @@ -1016,11 +1050,27 @@ export type ClearWhitespaceReturns = string; * * @since 1.0.0 */ +export type CondensePanelStatesCharacteristic = typeof Characteristic; + export type CondensePanelStatesPanelStates = PanelStatusStates; -export type CondensePanelStatesReturns = PortalPanelArmValue | undefined; +export type CondensePanelStatesReturnsArmValue = PortalPanelArmValue; + +export type CondensePanelStatesReturnsCharacteristicValue = CharacteristicValue; -export type CondensePanelStatesCondensed = PortalPanelArmValue | undefined; +export type CondensePanelStatesReturns = { + armValue: CondensePanelStatesReturnsArmValue; + characteristicValue: CondensePanelStatesReturnsCharacteristicValue; +} | undefined; + +export type CondensePanelStatesCondensedArmValue = PortalPanelArmValue; + +export type CondensePanelStatesCondensedCharacteristicValue = CharacteristicValue; + +export type CondensePanelStatesCondensed = { + armValue: CondensePanelStatesCondensedArmValue; + characteristicValue: CondensePanelStatesCondensedCharacteristicValue; +} | undefined; /** * Condensed sensor type items. @@ -1140,7 +1190,9 @@ export type DetectedNewPortalVersionReturns = Promise; * * @since 1.0.0 */ -export type DetectedNewSensorsInformationSensors = SensorsInformation; +export type DetectedNewSensorsInformationSensor = SensorInformation; + +export type DetectedNewSensorsInformationSensors = DetectedNewSensorsInformationSensor[]; export type DetectedNewSensorsInformationLogger = Logger | null; @@ -1153,7 +1205,9 @@ export type DetectedNewSensorsInformationReturns = Promise; * * @since 1.0.0 */ -export type DetectedNewSensorsStatusSensors = SensorsStatus; +export type DetectedNewSensorsStatusSensor = SensorStatus; + +export type DetectedNewSensorsStatusSensors = DetectedNewSensorsStatusSensor[]; export type DetectedNewSensorsStatusLogger = Logger | null; @@ -1161,6 +1215,30 @@ export type DetectedNewSensorsStatusDebugMode = boolean | null; export type DetectedNewSensorsStatusReturns = Promise; +/** + * Detected sensor count mismatch. + * + * @since 1.0.0 + */ +export type DetectedSensorCountMismatchDataSensorInfo = SensorInformation; + +export type DetectedSensorCountMismatchDataSensorsInfo = DetectedSensorCountMismatchDataSensorInfo[]; + +export type DetectedSensorCountMismatchDataSensorStatus = SensorStatus; + +export type DetectedSensorCountMismatchDataSensorsStatus = DetectedSensorCountMismatchDataSensorStatus[]; + +export type DetectedSensorCountMismatchData = { + sensorsInfo: DetectedSensorCountMismatchDataSensorsInfo; + sensorsStatus: DetectedSensorCountMismatchDataSensorsStatus; +}; + +export type DetectedSensorCountMismatchLogger = Logger | null; + +export type DetectedSensorCountMismatchDebugMode = boolean | null; + +export type DetectedSensorCountMismatchReturns = Promise; + /** * Detected unknown sensors action. * @@ -1308,6 +1386,45 @@ export type GenerateDynatracePCHeaderValueMode = 'keep-alive' | 'force-arm'; export type GenerateDynatracePCHeaderValueReturns = string; +/** + * Generate fake ready buttons. + * + * @since 1.0.0 + */ +export type GenerateFakeReadyButtonsButtons = OrbSecurityButtons; + +export type GenerateFakeReadyButtonsIsCleanState = ADTPulseSessionIsCleanState; + +export type GenerateFakeReadyButtonsOptionsRelativeUrl = PortalPanelArmButtonRelativeUrl; + +export type GenerateFakeReadyButtonsOptionsHref = PortalPanelArmButtonHref; + +export type GenerateFakeReadyButtonsOptionsSat = UUID; + +export type GenerateFakeReadyButtonsOptions = { + relativeUrl: GenerateFakeReadyButtonsOptionsRelativeUrl; + href: GenerateFakeReadyButtonsOptionsHref; + sat: GenerateFakeReadyButtonsOptionsSat; +}; + +export type GenerateFakeReadyButtonsReturns = (OrbSecurityButtonBase & OrbSecurityButtonReady)[]; + +export type GenerateFakeReadyButtonsReadyButtons = (OrbSecurityButtonBase & OrbSecurityButtonReady)[]; + +export type GenerateFakeReadyButtonsDisplayedButtonButtonText = PortalPanelArmButtonText; + +export type GenerateFakeReadyButtonsDisplayedButtonLoadingText = PortalPanelArmButtonLoadingText; + +export type GenerateFakeReadyButtonsDisplayedButtonArm = PortalPanelArmValue; + +export type GenerateFakeReadyButtonsDisplayedButton = { + buttonText: GenerateFakeReadyButtonsDisplayedButtonButtonText; + loadingText: GenerateFakeReadyButtonsDisplayedButtonLoadingText; + arm: GenerateFakeReadyButtonsDisplayedButtonArm; +}; + +export type GenerateFakeReadyButtonsDisplayedButtons = GenerateFakeReadyButtonsDisplayedButton[]; + /** * Generate hash. * @@ -1369,6 +1486,15 @@ export type InitializeReturns = void; */ export type IsForwardSlashOSReturns = boolean; +/** + * Is panel alarm active. + * + * @since 1.0.0 + */ +export type IsPanelAlarmActivePanelStatuses = PanelStatusStatuses; + +export type IsPanelAlarmActiveReturns = boolean; + /** * Is plugin outdated. * @@ -1512,9 +1638,9 @@ export type ParseDoSubmitHandlersUrlParamsArm = Exclude; -export type ParseOrbSensorsReturns = SensorsStatus; +export type ParseOrbSensorsReturns = SensorStatus[]; -export type ParseOrbSensorsSensors = SensorsStatus; +export type ParseOrbSensorsSensors = SensorStatus[]; export type ParseOrbSensorsCleanedIcon = PortalSensorStatusIcon; @@ -1573,9 +1699,9 @@ export type ParseOrbSecurityButtonsArm = PortalPanelArmValue; */ export type ParseOrbSensorsTableElements = NodeListOf; -export type ParseOrbSensorsTableReturns = SensorsInformation; +export type ParseOrbSensorsTableReturns = SensorInformation[]; -export type ParseOrbSensorsTableSensors = SensorsInformation; +export type ParseOrbSensorsTableSensors = SensorInformation[]; export type ParseOrbSensorsTableDeviceType = PortalSensorDeviceType; @@ -1673,14 +1799,15 @@ export type SleepReturns = Promise; * * @since 1.0.0 */ -export type StackTracerType = 'api-response' | 'detect-content' | 'sensor-mismatch' | 'serialize-error' | 'zod-error'; +export type StackTracerType = 'api-response' | 'detect-content' | 'fake-ready-buttons' | 'log-status-changes' | 'serialize-error' | 'zod-error'; export type StackTracerError = Type extends 'api-response' ? ApiResponseFail - : Type extends 'detect-content' ? object - : Type extends 'sensor-mismatch' ? object - : Type extends 'serialize-error' ? ErrorObject - : Type extends 'zod-error' ? z.ZodIssue[] - : never; + : Type extends 'detect-content' ? Record | Record[] + : Type extends 'fake-ready-buttons' ? { before: OrbSecurityButtons; after: OrbSecurityButtonBase & OrbSecurityButtonReady; } + : Type extends 'log-status-changes' ? { old: SensorInformation[], new: SensorInformation[] } + : Type extends 'serialize-error' ? ErrorObject + : Type extends 'zod-error' ? z.ZodIssue[] + : never; export type StackTracerReturns = void; diff --git a/src/types/shared.d.ts b/src/types/shared.d.ts index 2b99979..dd87282 100644 --- a/src/types/shared.d.ts +++ b/src/types/shared.d.ts @@ -441,7 +441,7 @@ export type PortalVersionContent = { }; /** - * Sensors information. + * Sensor information. * * @since 1.0.0 */ @@ -463,10 +463,8 @@ export type SensorInformation = { zone: SensorInformationZone; }; -export type SensorsInformation = SensorInformation[]; - /** - * Sensors status. + * Sensor status. * * @since 1.0.0 */ @@ -487,8 +485,6 @@ export type SensorStatus = { zone: SensorStatusZone; }; -export type SensorsStatus = SensorStatus[]; - /** * Sessions. *