UNPKG

homebridge-adt-pulse

Version:
804 lines 30.4 kB
import { createHash } from 'node:crypto'; import { createRequire } from 'node:module'; import os from 'node:os'; import util from 'node:util'; import chalk from 'chalk'; import { JSDOM } from 'jsdom'; import latestVersion from 'latest-version'; import _ from 'lodash'; import { DateTime } from 'luxon'; import semver from 'semver'; import { collectionDoSubmitHandlers, collectionOrbSecurityButtons, deviceGateways, deviceSecurityPanels, itemPanelStatusNotes, itemPanelStatusStates, itemPanelStatusStatuses, } from './items.js'; import { characterBackslashForwardSlash, characterHtmlLineBreak, characterWhitespace, functionDoSubmit, functionGoToUrl, functionSetArmState, paramSat, textOrbSensorZone, textOrbTextSummarySections, textSyncCode, } from './regex.js'; export function clearHtmlLineBreak(data) { return data.replace(characterHtmlLineBreak, ' ').trim(); } export function clearWhitespace(data) { return data.replace(characterWhitespace, ' ').trim(); } export function condensePanelStates(characteristic, panelStates) { let condensed; switch (true) { case panelStates.includes('Armed Away'): condensed = { armValue: 'away', characteristicValue: { current: characteristic.SecuritySystemCurrentState.AWAY_ARM, target: characteristic.SecuritySystemTargetState.AWAY_ARM, }, }; break; case panelStates.includes('Armed Night'): condensed = { armValue: 'night', characteristicValue: { current: characteristic.SecuritySystemCurrentState.NIGHT_ARM, target: characteristic.SecuritySystemTargetState.NIGHT_ARM, }, }; break; case panelStates.includes('Armed Stay'): condensed = { armValue: 'stay', characteristicValue: { current: characteristic.SecuritySystemCurrentState.STAY_ARM, target: characteristic.SecuritySystemTargetState.STAY_ARM, }, }; break; case panelStates.includes('Disarmed'): condensed = { armValue: 'off', characteristicValue: { current: characteristic.SecuritySystemCurrentState.DISARMED, target: characteristic.SecuritySystemTargetState.DISARM, }, }; break; default: break; } return condensed; } export function condenseSensorType(sensorType) { let condensed; switch (sensorType) { case 'Carbon Monoxide Detector': condensed = 'co'; break; case 'Door/Window Sensor': case 'Door Sensor': case 'Window Sensor': condensed = 'doorWindow'; break; case 'Fire (Smoke/Heat) Detector': condensed = 'fire'; break; case 'Water/Flood Sensor': condensed = 'flood'; break; case 'Glass Break Detector': condensed = 'glass'; break; case 'Heat (Rate-of-Rise) Detector': condensed = 'heat'; break; case 'Motion Sensor': case 'Motion Sensor (Notable Events Only)': condensed = 'motion'; break; case 'Shock Sensor': condensed = 'shock'; break; case 'Temperature Sensor': condensed = 'temperature'; break; default: break; } return condensed; } export function convertPanelCharacteristicValue(mode, characteristic, value) { switch (true) { case mode === 'current-to-target' && value === characteristic.SecuritySystemCurrentState.AWAY_ARM: return characteristic.SecuritySystemTargetState.AWAY_ARM; case mode === 'current-to-target' && value === characteristic.SecuritySystemCurrentState.STAY_ARM: return characteristic.SecuritySystemTargetState.STAY_ARM; case mode === 'current-to-target' && value === characteristic.SecuritySystemCurrentState.NIGHT_ARM: return characteristic.SecuritySystemTargetState.NIGHT_ARM; case mode === 'current-to-target' && value === characteristic.SecuritySystemCurrentState.DISARMED: return characteristic.SecuritySystemTargetState.DISARM; case mode === 'target-to-current' && value === characteristic.SecuritySystemTargetState.AWAY_ARM: return characteristic.SecuritySystemCurrentState.AWAY_ARM; case mode === 'target-to-current' && value === characteristic.SecuritySystemTargetState.STAY_ARM: return characteristic.SecuritySystemCurrentState.STAY_ARM; case mode === 'target-to-current' && value === characteristic.SecuritySystemTargetState.NIGHT_ARM: return characteristic.SecuritySystemCurrentState.NIGHT_ARM; case mode === 'target-to-current' && value === characteristic.SecuritySystemTargetState.DISARM: return characteristic.SecuritySystemCurrentState.DISARMED; default: return undefined; } } export function debugLog(logger, caller, type, message) { const logMessage = chalk.reset(chalk.underline(caller), '-', message, '...'); switch (type) { case 'error': if (logger !== null) { logger.error(chalk.gray('DEBUG'), chalk.redBright('ERROR:'), logMessage); } else { console.error('[ADT Pulse]', chalk.redBright('ERROR:'), logMessage); } break; case 'warn': if (logger !== null) { logger.warn(chalk.gray('DEBUG'), chalk.yellowBright('WARNING:'), logMessage); } else { console.warn('[ADT Pulse]', chalk.yellowBright('WARNING:'), logMessage); } break; case 'success': if (logger !== null) { logger.info(chalk.gray('DEBUG'), chalk.greenBright('SUCCESS:'), logMessage); } else { console.info('[ADT Pulse]', chalk.greenBright('SUCCESS:'), logMessage); } break; case 'info': if (logger !== null) { logger.info(chalk.gray('DEBUG'), chalk.blueBright('INFO:'), logMessage); } else { console.info('[ADT Pulse]', chalk.blueBright('INFO:'), logMessage); } break; default: break; } } export function fetchErrorMessage(response) { if (response === undefined || typeof response.data !== 'string') { return null; } const jsdom = new JSDOM(response.data, { url: response.config.url, referrer: response.config.headers.Referer, contentType: 'text/html', pretendToBeVisual: true, }); const warnMessage = jsdom.window.document.querySelector('#warnMsgContents'); if (warnMessage !== null) { return clearWhitespace(clearHtmlLineBreak(warnMessage.innerHTML)); } return null; } export function fetchMissingSatCode(response) { if (typeof response.data !== 'string') { return null; } const satCode = response.data.match(paramSat); if (satCode !== null && satCode.length >= 2) { return satCode[1]; } return null; } export function fetchTableCells(nodeElements, matchItems, incrementFrom, incrementTo) { const matched = {}; let newIncrementFrom = incrementFrom; let newIncrementTo = incrementTo; if (incrementFrom < 0) { newIncrementFrom = 0; } if (incrementTo < incrementFrom) { newIncrementTo = incrementFrom; } nodeElements.forEach((nodeElement, nodeElementKey) => { const currentNode = nodeElement.textContent; if (currentNode === null) { return; } const currentNodeCleaned = clearWhitespace(currentNode); const collectedNodes = []; if (!matchItems.includes(currentNodeCleaned)) { return; } for (let i = newIncrementFrom; i <= newIncrementTo; i += 1) { const incrementedNode = nodeElements[nodeElementKey + i].textContent; if (incrementedNode !== null) { const incrementedNodeCleaned = clearWhitespace(incrementedNode); if (incrementedNodeCleaned !== '') { collectedNodes.push(incrementedNodeCleaned); } } } if (collectedNodes.length > 0) { matched[currentNodeCleaned] = collectedNodes; } }); return matched; } export function findGatewayManufacturerModel(mode, manufacturer, model) { let newManufacturer = manufacturer; let newModel = model; switch (true) { case manufacturer === 'ADT Pulse Gateway' && model === 'iHub-3001': newManufacturer = 'iControl'; newModel = 'ADT Pulse Gateway iHub-3001'; break; case manufacturer === 'ADT Pulse Gateway' && model === 'PGZNG1': newManufacturer = 'NETGEAR'; newModel = 'ADT Pulse Gateway PGZNG1'; break; case manufacturer === null && model === 'Compact SMA Protocol Gateway': newManufacturer = 'iControl'; newModel = 'Compact SMA Protocol Gateway'; break; case manufacturer === null && model === 'Lynx/QuickConnect Cellular-Only Gateway': newManufacturer = 'Ademco/ADT'; newModel = 'Lynx/QuickConnect Cellular-Only Gateway'; break; default: break; } return (mode === 'manufacturer') ? newManufacturer : newModel; } export function findIndexWithValue(array, condition) { let index = -1; let value; for (let i = 0; i < array.length; i += 1) { if (condition(array[i])) { index = i; value = array[i]; break; } } return { index, value, }; } export function findNullKeys(properties, parentKey = '') { const found = []; Object.entries(properties).forEach(([propertyKey, property]) => { const currentKey = (parentKey !== '') ? `${parentKey}.${propertyKey}` : propertyKey; if (_.isPlainObject(property)) { found.push(...findNullKeys(property, currentKey)); } else if (property == null) { found.push(currentKey); } }); return found; } export function findPanelManufacturer(manufacturerProvider, typeModel) { if (manufacturerProvider !== null) { return manufacturerProvider; } switch (typeModel) { case 'Security Panel - LYNX/QuickConnect': return 'Ademco/ADT'; default: return null; } } export function generateHash(data) { const staticData = _.omit(data, [ 'masterCode', 'network.broadband.ip', 'network.broadband.mac', 'network.device.ip', 'network.device.mac', 'network.router.lanIp', 'network.router.wanIp', 'rawHtml', 'response.Broadband LAN IP Address:', 'response.Broadband LAN MAC:', 'response.Device LAN IP Address:', 'response.Device LAN MAC:', 'response.Last Update:', 'response.Next Update:', 'response.Router LAN IP Address:', 'response.Router WAN IP Address:', 'response.Security Panel Master Code:', 'response.Serial Number:', 'serialNumber', 'update.last', 'update.next', ]); return createHash('sha512').update(JSON.stringify(staticData)).digest('hex'); } export function getAccessoryCategory(deviceCategory) { switch (deviceCategory) { case 'ALARM_SYSTEM': return 11; case 'OTHER': return 1; case 'SECURITY_SYSTEM': return 11; case 'SENSOR': return 10; case 'SWITCH': return 8; default: return 1; } } export function getDetectReportUrl() { return 'https://mtnu8h3zgw5c7fy24r9d.ntfy.mrjackyliang.com'; } export function getPackageVersion() { const require = createRequire(import.meta.url); const packageJson = require('../../package.json'); return packageJson.version; } export function getPluralForm(count, singular, plural) { if (count === 1) { return singular; } return plural; } export function isEmptyOrbTextSummary(input) { const match = { panelStates: [], panelStatuses: [], panelNotes: [], rawData: { node: '', unknownPieces: [], }, }; return _.isEqual(match, input); } export function isForwardSlashOS() { const currentOS = os.platform(); return [ 'aix', 'android', 'darwin', 'freebsd', 'haiku', 'linux', 'openbsd', 'sunos', 'netbsd', ].includes(currentOS); } export function isMaintenancePeriod() { const now = DateTime.now().setZone('America/Los_Angeles'); const startTime = now.set({ hour: 22, minute: 0, second: 0, millisecond: 0, }); const endTime = now.set({ hour: 4, minute: 0, second: 0, millisecond: 0, }); return (now >= startTime || now < endTime); } export function isPanelAlarmActive(panelStatuses, orbSecurityButtons, ignoreSensorProblem) { const hasDisarmedTroubleButtons = orbSecurityButtons.filter((orbSecurityButton) => { const orbSecurityButtonButtonText = orbSecurityButton.buttonText; return orbSecurityButtonButtonText !== null && ['Disarm', 'Arm Away', 'Arm Stay'].includes(orbSecurityButtonButtonText); }).length === 3; return (panelStatuses.includes('BURGLARY ALARM') || panelStatuses.includes('Carbon Monoxide Alarm') || panelStatuses.includes('FIRE ALARM') || (!ignoreSensorProblem && panelStatuses.includes('Sensor Problem') && hasDisarmedTroubleButtons) || (!ignoreSensorProblem && panelStatuses.includes('Sensor Problems') && hasDisarmedTroubleButtons) || panelStatuses.includes('Uncleared Alarm') || panelStatuses.includes('WATER ALARM')); } export async function isPluginOutdated() { const currentVersion = getPackageVersion(); if (currentVersion === undefined) { return false; } try { const fetchedVersion = await latestVersion('homebridge-adt-pulse'); if (semver.gt(fetchedVersion, currentVersion)) { return true; } } catch { } return false; } export function isPortalSyncCode(syncCode) { return textSyncCode.test(syncCode); } export function isSessionCleanState(orbSecurityButtons) { if (orbSecurityButtons.some((orbSecurityButton) => orbSecurityButton.buttonDisabled)) { return false; } return orbSecurityButtons .filter((orbSecurityButton) => !orbSecurityButton.buttonDisabled) .every((orbSecurityButton) => !['disarmed', 'disarmed+with+alarm', 'night+stay'].includes(orbSecurityButton.urlParams.armState)); } export function isUnknownDoSubmitHandlerCollection(handlers) { const currentHandlerCollection = handlers.map((handler) => ({ href: handler.urlParams.href, })); return !collectionDoSubmitHandlers .map((collectionDoSubmitHandler) => collectionDoSubmitHandler.handlers) .some((collectionDoSubmitHandler) => _.isEqual(collectionDoSubmitHandler, currentHandlerCollection)); } export function isUnknownGatewayDevice(gateway) { const currentGateway = { manufacturer: _.get(gateway, ['Manufacturer:', 0], null), model: _.get(gateway, ['Model:', 0], null), primaryConnectionType: _.get(gateway, ['Primary Connection Type:', 0], null), }; return !deviceGateways .map((deviceGateway) => deviceGateway.gateway) .some((deviceGateway) => _.isEqual(deviceGateway, currentGateway)); } export function isUnknownOrbSecurityButtonCollection(buttons) { const currentButtonCollection = buttons.map((button) => ({ buttonDisabled: button.buttonDisabled, buttonText: button.buttonText, loadingText: ('loadingText' in button) ? button.loadingText : null, })); return !collectionOrbSecurityButtons .map((collectionOrbSecurityButton) => collectionOrbSecurityButton.buttons) .some((collectionOrbSecurityButton) => _.isEqual(collectionOrbSecurityButton, currentButtonCollection)); } export function isUnknownPanelDevice(panel) { const currentPanel = { manufacturerProvider: _.get(panel, ['Manufacturer/Provider:', 0], null), typeModel: _.get(panel, ['Type/Model:', 0], null), }; return !deviceSecurityPanels .map((deviceSecurityPanel) => deviceSecurityPanel.panel) .some((deviceSecurityPanel) => _.isEqual(deviceSecurityPanel, currentPanel)); } export function parseArmDisarmMessage(element) { if (element === null || element.textContent === null) { return null; } return clearWhitespace(element.textContent); } export function parseDoSubmitHandlers(elements) { const handlers = []; elements.forEach((element) => { const onClick = element.getAttribute('onclick'); if (onClick === null) { return; } const relativeUrl = onClick.replace(functionDoSubmit, '$1'); const urlParamsSat = onClick.replace(functionDoSubmit, '$2'); const urlParamsHref = onClick.replace(functionDoSubmit, '$3').replace(characterBackslashForwardSlash, '/'); const urlParamsArmState = onClick.replace(functionDoSubmit, '$5'); const urlParamsArm = onClick.replace(functionDoSubmit, '$6'); handlers.push({ relativeUrl, urlParams: { arm: (urlParamsArm !== '') ? urlParamsArm : null, armState: (urlParamsArmState !== '') ? urlParamsArmState : null, href: urlParamsHref, sat: urlParamsSat, }, }); }); return handlers; } export function parseMultiFactorMethods(response) { const methods = []; response.state.mfaProperties.forEach((mfaProperty) => { const mfaPropertyId = mfaProperty.id; const mfaPropertyType = mfaProperty.type; const mfaPropertyLabel = mfaProperty.label; methods.push({ id: mfaPropertyId, type: mfaPropertyType, label: mfaPropertyLabel, }); }); return methods; } export function parseMultiFactorTrustedDevices(response) { const devices = []; if (response.state.trustedDevices === undefined) { return devices; } response.state.trustedDevices.forEach((trustedDevice) => { const trustedDeviceId = trustedDevice.id; const trustedDeviceName = trustedDevice.name; devices.push({ id: trustedDeviceId, name: trustedDeviceName, }); }); return devices; } export function parseOrbSensors(elements) { const sensors = []; elements.forEach((element) => { const canvas = element.querySelector('td:nth-child(1) canvas'); const name = element.querySelector('td:nth-child(3) a.p_deviceNameText'); const zone = element.querySelector('td:nth-child(3) span.p_grayNormalText, td:nth-child(3) div.p_grayNormalText'); const status = element.querySelector('td:nth-child(4)'); if (canvas !== null && name !== null && zone !== null && status !== null) { const canvasIcon = canvas.getAttribute('icon'); const nameText = name.textContent; const zoneText = zone.textContent; const statusText = status.textContent; if (canvasIcon !== null && nameText !== null && zoneText !== null && zoneText.startsWith('Zone') && statusText !== null) { const cleanedIcon = clearWhitespace(canvasIcon); const cleanedName = clearWhitespace(nameText); const cleanedZone = Number(clearWhitespace(zoneText).replace(textOrbSensorZone, '$2')); const cleanedStatuses = clearWhitespace(statusText).split(', '); if (cleanedZone < 1 || cleanedZone > 99) { return; } sensors.push({ icon: cleanedIcon, name: cleanedName, statuses: cleanedStatuses, zone: cleanedZone, }); } } }); return sensors.sort((a, b) => a.zone - b.zone); } export function parseOrbTextSummary(element) { if (element === null || element.textContent === null) { return { panelStates: [], panelStatuses: [], panelNotes: [], rawData: { node: '', unknownPieces: [], }, }; } const cleanedNode = clearWhitespace(element.textContent); const rawSections = cleanedNode.split(textOrbTextSummarySections).filter(Boolean).map((section) => section.split(', ')); const finalParsed = { panelStates: [], panelStatuses: [], panelNotes: [], rawData: { node: cleanedNode, unknownPieces: [], }, }; for (let i = 0; i < rawSections.length; i += 1) { for (let j = 0; j < rawSections[i].length; j += 1) { switch (true) { case itemPanelStatusStates.includes(rawSections[i][j]): finalParsed.panelStates.push(rawSections[i][j]); break; case itemPanelStatusStatuses.includes(rawSections[i][j]): finalParsed.panelStatuses.push(rawSections[i][j]); break; case itemPanelStatusNotes.includes(rawSections[i][j]): finalParsed.panelNotes.push(rawSections[i][j]); break; default: finalParsed.rawData.unknownPieces.push(rawSections[i][j]); break; } } } return finalParsed; } export function parseOrbSecurityButtons(elements) { const buttons = []; elements.forEach((element) => { const disabled = element.getAttribute('disabled'); const id = element.getAttribute('id'); const value = element.getAttribute('value'); const onClick = element.getAttribute('onclick'); const buttonId = id; if (disabled !== null && onClick === null) { const pendingButtonText = value; buttons.push({ buttonDisabled: true, buttonId, buttonText: pendingButtonText, }); return; } if (disabled === null && onClick !== null) { const readyButtonText = value; const relativeUrl = onClick.replace(functionSetArmState, '$1'); const loadingText = onClick.replace(functionSetArmState, '$2'); const buttonIndex = Number(onClick.replace(functionSetArmState, '$3')); const totalButtons = Number(onClick.replace(functionSetArmState, '$4')); const changeAccessCode = Boolean(onClick.replace(functionSetArmState, '$5')); const urlParamsHref = onClick.replace(functionSetArmState, '$6'); const urlParamsArmState = onClick.replace(functionSetArmState, '$7'); const urlParamsArm = onClick.replace(functionSetArmState, '$8'); const urlParamsSat = onClick.replace(functionSetArmState, '$9'); buttons.push({ buttonDisabled: false, buttonId, buttonIndex, buttonText: readyButtonText, changeAccessCode, loadingText, relativeUrl, totalButtons, urlParams: { arm: urlParamsArm, armState: urlParamsArmState, href: urlParamsHref, sat: urlParamsSat, }, }); } }); return buttons; } export function parseSensorsTable(type, elements) { const sensors = []; let atSensorsSection = false; elements.forEach((element) => { const elementCount = element.childElementCount; const title = element.querySelector('td.p_listRow h2.p_boldNormalText'); if (atSensorsSection) { if (title === null && elementCount === 1) { atSensorsSection = false; return; } const onclick = element.getAttribute('onclick'); const icon = element.querySelector('td:nth-child(1) canvas'); const name = element.querySelector('td:nth-child(2) a'); const zone = element.querySelector('td:nth-child(3)'); const deviceType = element.querySelector('td:nth-child(5)'); if (onclick !== null && icon !== null && name !== null && zone !== null && deviceType !== null) { const deviceId = onclick.replace(functionGoToUrl, '$1'); const iconTitle = icon.getAttribute('title'); const nameText = name.textContent; const zoneText = zone.textContent; const deviceTypeText = deviceType.textContent; if (iconTitle !== null && nameText !== null && zoneText !== null && deviceTypeText !== null) { const cleanedDeviceId = Number(clearWhitespace(deviceId)); const cleanedDeviceType = clearWhitespace(deviceTypeText); const cleanedName = clearWhitespace(nameText); const cleanedStatus = clearWhitespace(iconTitle); const cleanedZone = Number(clearWhitespace(zoneText)); if ([ 'System/Supervisory', 'Unknown Device Type', 'Unknown Device Type (Notable Events Only)', ].includes(cleanedDeviceType)) { return; } if (type === 'sensors-config') { const condensedType = condenseSensorType(cleanedDeviceType); if (condensedType !== undefined) { sensors.push({ adtName: cleanedName, adtZone: cleanedZone, adtType: condensedType, }); } } else { sensors.push({ deviceId: cleanedDeviceId, deviceType: cleanedDeviceType, name: cleanedName, status: cleanedStatus, zone: cleanedZone, }); } } } } if (!atSensorsSection) { if (title !== null && elementCount === 1) { const titleText = title.textContent; if (titleText !== null) { const cleanedTitle = clearWhitespace(titleText); if (cleanedTitle === 'Sensors') { atSensorsSection = true; } } } } }); if (type === 'sensors-config') { return sensors.sort((a, b) => a.adtZone - b.adtZone); } return sensors.sort((a, b) => a.zone - b.zone); } export function removePersonalIdentifiableInformation(data) { const redactedKeys = [ 'Broadband LAN IP Address:', 'Broadband LAN MAC:', 'Device LAN IP Address:', 'Device LAN MAC:', 'ip', 'Last Update:', 'lanIp', 'last', 'mac', 'masterCode', 'Next Update:', 'next', 'Router LAN IP Address:', 'Router WAN IP Address:', 'sat', 'Security Panel Master Code:', 'serial', 'serialNumber', 'Serial Number:', 'wanIp', ]; const replacementText = '*** REDACTED FOR PRIVACY ***'; const replaceValue = (object) => { const modifiedObject = {}; Object.keys(object).forEach((key) => { const value = object[key]; if (_.isPlainObject(value)) { modifiedObject[key] = replaceValue(value); } else if (Array.isArray(value)) { modifiedObject[key] = value.map((item) => { if (_.isPlainObject(item)) { return replaceValue(item); } if (redactedKeys.includes(key)) { return replacementText; } return item; }); } else if (redactedKeys.includes(key)) { modifiedObject[key] = replacementText; } else { modifiedObject[key] = value; } }); return modifiedObject; }; if (Array.isArray(data)) { return data.map(replaceValue); } return replaceValue(data); } export async function sleep(milliseconds) { return new Promise((resolve) => { setTimeout(resolve, milliseconds); }); } export function stackTracer(type, error) { let stringError; switch (type) { case 'api-response': case 'config-content': case 'detect-content': case 'fake-ready-buttons': case 'log-status-changes': case 'serialize-error': stringError = util.inspect(error, { showHidden: false, depth: Infinity, colors: false, }); break; case 'zod-error': stringError = JSON.stringify(error, null, 2).replace(/\\"/g, '\''); break; default: break; } console.error(chalk.yellowBright(stringError)); } //# sourceMappingURL=utility.js.map