UNPKG

homebridge-nest-accfactory

Version:

Homebridge support for Nest/Google devices including HomeKit Secure Video (HKSV) support for doorbells and cameras

799 lines (716 loc) 30.2 kB
// Nest Protect - HomeKit integration // Part of homebridge-nest-accfactory // // HomeKit accessory implementation for Nest Protect devices. // Provides smoke and carbon monoxide detection, battery monitoring, // alarm state handling, and activity tracking using Nest and Google APIs. // // Responsibilities: // - Expose smoke and carbon monoxide detection via HomeKit services // - Synchronise alarm states with HomeKit (smoke, CO, test events) // - Monitor battery status and low battery conditions // - Report device status (online/offline, fault conditions) // - Record event history for Eve Home integration // // Services: // - SmokeSensor // - CarbonMonoxideSensor // - Battery (hidden, for battery level and low battery alerts) // // Features: // - Real-time smoke and CO alarm state synchronisation // - Support for alarm test events // - Battery monitoring with low battery alerts // - Device online/offline state tracking // - Eve Home history integration // // Notes: // - Supports both Nest and Google APIs depending on device/account type // - Alarm states are normalised before presentation to HomeKit // - HomeKit does not differentiate alarm sources beyond service type // // Data Translation: // - Raw API data is mapped using PROTECT_FIELD_MAP // - processRawData() builds device objects and applies configuration overrides // - Field mapping isolates upstream API differences from HomeKit representation // // Mark Hulskamp 'use strict'; // Define our modules import HomeKitDevice from '../HomeKitDevice.js'; import { processSoftwareVersion, scaleValue } from '../utils.js'; import { buildMappedObject, createMappingContext } from '../translator.js'; // Define constants import { LOW_BATTERY_LEVEL, DATA_SOURCE, PROTOBUF_RESOURCES, DEVICE_TYPE } from '../consts.js'; export default class NestProtect extends HomeKitDevice { static TYPE = DEVICE_TYPE.PROTECT; static VERSION = '2026.05.05'; // Code version batteryService = undefined; smokeService = undefined; motionService = undefined; carbonMonoxideService = undefined; // Class functions onAdd() { // Setup the smoke sensor service if not already present on the accessory this.smokeService = this.addHKService(this.hap.Service.SmokeSensor, '', 1, { messages: this.message.bind(this), EveSmoke_lastalarmtest: this.deviceData.latest_alarm_test, EveSmoke_alarmtest: this.deviceData.self_test_in_progress, EveSmoke_heatstatus: this.deviceData.heat_status, EveSmoke_hushedstate: this.deviceData.hushed_state, EveSmoke_statusled: this.deviceData.ntp_green_led_enable, EveSmoke_smoketestpassed: this.deviceData.smoke_test_passed, EveSmoke_heattestpassed: this.deviceData.heat_test_passed, }); this.smokeService.setPrimaryService(); this.addHKCharacteristic(this.smokeService, this.hap.Characteristic.StatusActive); this.addHKCharacteristic(this.smokeService, this.hap.Characteristic.StatusFault); // Setup the carbon monoxide service if not already present on the accessory this.carbonMonoxideService = this.addHKService(this.hap.Service.CarbonMonoxideSensor, '', 1); // Setup battery service if not already present on the accessory this.batteryService = this.addHKService(this.hap.Service.Battery, '', 1); this.batteryService.setHiddenService(true); // Setup motion service if not already present on the accessory and Nest protect is a wired version if (this.deviceData?.wired_or_battery === false) { this.motionService = this.addHKService(this.hap.Service.MotionSensor, '', 1); this.motionService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); // No motion initially this.postSetupDetail('With motion sensor'); } } onRemove() { this.accessory.removeService(this.smokeService); this.accessory.removeService(this.carbonMonoxideService); this.accessory.removeService(this.batteryService); this.accessory.removeService(this.motionService); this.smokeService = undefined; this.carbonMonoxideService = undefined; this.batteryService = undefined; this.motionService = undefined; } onShutdown() { // Clear motion sensor on shutdown to prevent stale status in HomeKit after restart if (this.motionService !== undefined) { this.motionService.updateCharacteristic(this.hap.Characteristic.MotionDetected, false); } } onUpdate(deviceData) { if ( typeof deviceData !== 'object' || deviceData?.constructor !== Object || this.smokeService === undefined || this.carbonMonoxideService === undefined || this.batteryService === undefined ) { return; } // Update battery level and status if (deviceData.battery_level !== undefined) { this.batteryService.updateCharacteristic(this.hap.Characteristic.BatteryLevel, deviceData.battery_level); this.batteryService.updateCharacteristic( this.hap.Characteristic.StatusLowBattery, deviceData.battery_level > LOW_BATTERY_LEVEL ? this.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL : this.hap.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW, ); } this.batteryService.updateCharacteristic(this.hap.Characteristic.ChargingState, this.hap.Characteristic.ChargingState.NOT_CHARGEABLE); // Update smoke details // If protect isn't online, replacement date past, report in HomeKit this.smokeService.updateCharacteristic( this.hap.Characteristic.StatusActive, deviceData.online === true && Math.floor(Date.now() / 1000) <= deviceData.replacement_date, ); this.smokeService.updateCharacteristic( this.hap.Characteristic.StatusFault, deviceData.online === true && Math.floor(Date.now() / 1000) <= deviceData.replacement_date ? this.hap.Characteristic.StatusFault.NO_FAULT : this.hap.Characteristic.StatusFault.GENERAL_FAULT, ); this.smokeService.updateCharacteristic( this.hap.Characteristic.SmokeDetected, deviceData.smoke_status === true ? this.hap.Characteristic.SmokeDetected.SMOKE_DETECTED : this.hap.Characteristic.SmokeDetected.SMOKE_NOT_DETECTED, ); if (deviceData.smoke_status === true && this.deviceData.smoke_status === false) { this?.log?.warn?.('Smoke detected in "%s"', deviceData.description); } if (deviceData.smoke_status === false && this.deviceData.smoke_status === true) { this?.log?.info?.('Smoke is no longer detected in "%s"', deviceData.description); } // Update carbon monoxide details this.carbonMonoxideService.updateCharacteristic( this.hap.Characteristic.CarbonMonoxideDetected, deviceData.co_status === true ? this.hap.Characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL : this.hap.Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL, ); if (deviceData.co_status === true && this.deviceData.co_status === false) { this?.log?.warn?.('Abnormal carbon monoxide levels detected in "%s"', deviceData.description); } if (deviceData.co_status === false && this.deviceData.co_status === true) { this?.log?.info?.('Carbon monoxide levels have returned to normal in "%s"', deviceData.description); } // Update self testing details if (deviceData.self_test_in_progress === true && this.deviceData.self_test_in_progress === false) { this?.log?.warn?.('Smoke and Carbon monoxide sensor testing has started in "%s"', deviceData.description); } if (deviceData.self_test_in_progress === false && this.deviceData.self_test_in_progress === true) { this?.log?.info?.('Smoke and Carbon monoxide sensor testing completed in "%s"', deviceData.description); } // Update motion service if present if (this.motionService !== undefined) { this.motionService.updateCharacteristic(this.hap.Characteristic.MotionDetected, deviceData.detected_motion === true); if (this.deviceData?.logMotionEvents === true && deviceData?.detected_motion === true && this.deviceData?.detected_motion !== true) { this?.log?.info?.('Motion detected in "%s"', deviceData.description); } // Log motion to history only if changed to previous recording this.history(this.motionService, { status: deviceData.detected_motion === true ? 1 : 0, }); } // Update our internal data with properties Eve will need to process then Notify Eve App of device status changes if linked this.deviceData.latest_alarm_test = deviceData.latest_alarm_test; this.deviceData.self_test_in_progress = deviceData.self_test_in_progress; this.deviceData.heat_status = deviceData.heat_status; this.deviceData.ntp_green_led_enable = deviceData.ntp_green_led_enable; this.deviceData.smoke_test_passed = deviceData.smoke_test_passed; this.deviceData.heat_test_passed = deviceData.heat_test_passed; this.historyService?.updateEveHome?.(this.smokeService); } onMessage(type, message) { if (typeof type !== 'string' || type === '' || (message !== undefined && (typeof message !== 'object' || message === null))) { return; } if (type === HomeKitDevice?.EVEHOME?.GET) { // Pass back extra data for Eve Smoke onGet() to process command // Data will already be an object, our only job is to add/modify to it message.lastalarmtest = this.deviceData.latest_alarm_test; message.alarmtest = this.deviceData.self_test_in_progress; message.heatstatus = this.deviceData.heat_status; message.statusled = this.deviceData.ntp_green_led_enable; message.smoketestpassed = this.deviceData.smoke_test_passed; message.heattestpassed = this.deviceData.heat_test_passed; message.hushedstate = this.deviceData.hushed_state; return message; } if (type === HomeKitDevice?.EVEHOME?.SET) { if (typeof message?.alarmtest === 'boolean') { // TODO - How do we trigger an alarm test :-) //this?.log?.info?.('Eve Smoke Alarm test', (message.alarmtest === true ? 'start' : 'stop')); } if (typeof message?.statusled === 'boolean') { this.set({ uuid: this.deviceData.nest_google_device_uuid, ntp_green_led_enable: message.statusled }); } } } } // Protect field translation map // Maps raw source data -> normalised protect device fields // - fields: top-level raw fields this mapping depends on (for delta updates) // - related: top-level raw fields on related objects this mapping depends on // - translate: converts raw -> final normalised value const PROTECT_FIELD_MAP = { // Identity fields serialNumber: { required: true, google: { fields: [], translate: ({ raw }) => typeof raw?.value?.device_identity?.serialNumber === 'string' && raw.value.device_identity.serialNumber.trim() !== '' ? raw.value.device_identity.serialNumber.trim().toUpperCase() : undefined, }, nest: { fields: [], translate: ({ raw }) => typeof raw?.value?.serial_number === 'string' && raw.value.serial_number.trim() !== '' ? raw.value.serial_number.trim().toUpperCase() : undefined, }, }, nest_google_device_uuid: { required: true, google: { fields: [], translate: ({ objectKey }) => objectKey, }, nest: { fields: [], translate: ({ objectKey }) => objectKey, }, }, nest_google_home_uuid: { google: { fields: ['device_info'], translate: ({ raw }) => raw?.value?.device_info?.pairerId?.resourceId, }, nest: { fields: ['structure_id'], translate: ({ raw }) => typeof raw?.value?.structure_id === 'string' && raw.value.structure_id.trim() !== '' ? 'structure.' + raw.value.structure_id.trim() : undefined, }, }, // Naming / descriptive fields model: { required: true, google: { fields: ['device_info'], translate: ({ raw }) => { let typeName = raw?.value?.device_info?.typeName ?? ''; return typeName === 'nest.resource.NestProtect1LinePoweredResource' ? 'Protect (1st gen, wired)' : typeName === 'nest.resource.NestProtect1BatteryPoweredResource' ? 'Protect (1st gen, battery)' : typeName === 'nest.resource.NestProtect2LinePoweredResource' ? 'Protect (2nd gen, wired)' : typeName === 'nest.resource.NestProtect2BatteryPoweredResource' ? 'Protect (2nd gen, battery)' : 'Protect (unknown)'; }, }, nest: { fields: ['serial_number', 'wired_or_battery'], translate: ({ raw }) => { let model = raw?.value?.serial_number?.substring?.(0, 2) === '06' ? 'Protect (2nd gen)' : raw?.value?.serial_number?.substring?.(0, 2) === '05' ? 'Protect (1st gen)' : 'Protect (unknown)'; return raw?.value?.wired_or_battery === true ? model.replace(/\bgen\)/, 'gen, battery)') : raw?.value?.wired_or_battery === false ? model.replace(/\bgen\)/, 'gen, wired)') : model; }, }, }, softwareVersion: { required: true, google: { fields: ['device_identity'], translate: ({ raw }) => typeof raw?.value?.device_identity?.softwareVersion === 'string' && raw.value.device_identity.softwareVersion.trim() !== '' ? processSoftwareVersion(raw.value.device_identity.softwareVersion) : undefined, }, nest: { fields: ['software_version'], translate: ({ raw }) => typeof raw?.value?.software_version === 'string' && raw.value.software_version.trim() !== '' ? processSoftwareVersion(raw.value.software_version) : undefined, }, }, description: { required: true, google: { fields: ['label', 'device_info', 'device_located_settings'], related: ['located_annotations'], translate: ({ rawData, raw }) => { let description = String(raw?.value?.label?.label ?? '').trim(); let location = String(raw?.value?.device_located_settings?.whereLabel?.literal ?? '').trim(); if (location === '') { location = String( [ ...Object.values( rawData?.[raw?.value?.device_info?.pairerId?.resourceId]?.value?.located_annotations?.predefinedWheres || {}, ), ...Object.values(rawData?.[raw?.value?.device_info?.pairerId?.resourceId]?.value?.located_annotations?.customWheres || {}), ].find((where) => where?.whereId?.resourceId === raw?.value?.device_located_settings?.whereAnnotationRid?.resourceId)?.label ?.literal ?? '', ).trim(); } if (description.toUpperCase() === location.toUpperCase()) { location = ''; } if (description === '' && location !== '') { description = location; location = ''; } if (description === '' && location === '') { description = 'unknown description'; } return HomeKitDevice.makeValidHKName(location === '' ? description : description + ' - ' + location); }, }, nest: { fields: ['description', 'structure_id', 'where_id'], related: ['wheres'], translate: ({ rawData, raw }) => { let description = String(raw?.value?.description ?? '').trim(); let location = String( rawData?.['where.' + raw?.value?.structure_id]?.value?.wheres?.find((where) => where?.where_id === raw?.value?.where_id)?.name ?? '', ).trim(); if (description.toUpperCase() === location.toUpperCase()) { location = ''; } if (description === '' && location !== '') { description = location; location = ''; } if (description === '' && location === '') { description = 'unknown description'; } return HomeKitDevice.makeValidHKName(location === '' ? description : description + ' - ' + location); }, }, }, // Core state online: { required: true, google: { fields: ['liveness'], translate: ({ raw }) => raw?.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE', }, nest: { fields: ['thread_mac_address'], related: ['online'], translate: ({ rawData, raw }) => rawData?.['widget_track.' + raw?.value?.thread_mac_address?.toUpperCase?.()]?.value?.online === true, }, }, line_power_present: { google: { fields: ['wall_power'], translate: ({ raw }) => raw?.value?.wall_power?.status === 'POWER_SOURCE_STATUS_ACTIVE', }, nest: { fields: ['line_power_present'], translate: ({ raw }) => raw?.value?.line_power_present === true, }, }, wired_or_battery: { google: { fields: ['wall_power'], translate: ({ raw }) => raw?.value?.wall_power?.present !== true, }, nest: { fields: ['wired_or_battery'], translate: ({ raw }) => raw?.value?.wired_or_battery === 1, }, }, battery_level: { google: { fields: ['battery_voltage_bank0', 'battery_voltage_bank1'], translate: ({ raw }) => { let bank0 = Number.isFinite(Number(raw?.value?.battery_voltage_bank0?.batteryValue?.batteryVoltage?.value)) === true && Number(raw.value.battery_voltage_bank0.batteryValue.batteryVoltage.value) > 0 ? Number(raw.value.battery_voltage_bank0.batteryValue.batteryVoltage.value) : undefined; let bank1 = Number.isFinite(Number(raw?.value?.battery_voltage_bank1?.batteryValue?.batteryVoltage?.value)) === true && Number(raw.value.battery_voltage_bank1.batteryValue.batteryVoltage.value) > 0 ? Number(raw.value.battery_voltage_bank1.batteryValue.batteryVoltage.value) : undefined; let voltage = Number.isFinite(bank0) === true && Number.isFinite(bank1) === true ? Math.min(bank0, bank1) : Number.isFinite(bank0) === true ? bank0 : Number.isFinite(bank1) === true ? bank1 : undefined; return Number.isFinite(voltage) === true ? Math.round(scaleValue(voltage, 4.5, 5.4, 0, 100)) : undefined; }, }, nest: { fields: ['battery_level'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.battery_level)) === true && Number(raw.value.battery_level) > 0 ? Math.round(scaleValue(Number(raw.value.battery_level), 4500, 5400, 0, 100)) : undefined, }, }, smoke_status: { required: true, google: { fields: ['safety_alarm_smoke'], translate: ({ raw }) => raw?.value?.safety_alarm_smoke?.alarmState === 'ALARM_STATE_ALARM', }, nest: { fields: ['smoke_status'], translate: ({ raw }) => raw?.value?.smoke_status !== 0, }, }, co_status: { required: true, google: { fields: ['safety_alarm_co'], translate: ({ raw }) => raw?.value?.safety_alarm_co?.alarmState === 'ALARM_STATE_ALARM', }, nest: { fields: ['co_status'], translate: ({ raw }) => raw?.value?.co_status !== 0, }, }, heat_status: { google: { fields: [], translate: () => false, // TODO <- need to find in protobuf }, nest: { fields: ['heat_status'], translate: ({ raw }) => raw?.value?.heat_status !== 0, }, }, hushed_state: { google: { fields: ['safety_alarm_smoke', 'safety_alarm_co'], translate: ({ raw }) => raw?.value?.safety_alarm_smoke?.silenceState === 'SILENCE_STATE_SILENCED' || raw?.value?.safety_alarm_co?.silenceState === 'SILENCE_STATE_SILENCED', }, nest: { fields: ['hushed_state'], translate: ({ raw }) => raw?.value?.hushed_state === true, }, }, ntp_green_led_enable: { google: { fields: ['night_time_promise_settings'], translate: ({ raw }) => raw?.value?.night_time_promise_settings?.greenLedEnabled === true, }, nest: { fields: ['ntp_green_led_enable'], translate: ({ raw }) => raw?.value?.ntp_green_led_enable === true, }, }, smoke_test_passed: { google: { fields: ['safety_summary', 'smoke'], translate: ({ raw }) => raw?.value?.safety_summary?.warningDevices?.failures?.includes?.('FAILURE_TYPE_SMOKE') === false ? true : typeof raw?.value?.smoke !== 'object' ? undefined : raw?.value?.smoke?.infraredLedFault === undefined || raw?.value?.smoke?.blueLedFault === undefined ? true : raw?.value?.smoke?.infraredLedFault?.type === 'SMOKE_FAULT_TYPE_NONE' && raw?.value?.smoke?.blueLedFault?.type === 'SMOKE_FAULT_TYPE_NONE', }, nest: { fields: ['component_smoke_test_passed'], translate: ({ raw }) => raw?.value?.component_smoke_test_passed === true, }, }, heat_test_passed: { google: { fields: ['safety_summary', 'passive_infrared'], translate: ({ raw }) => raw?.value?.safety_summary?.warningDevices?.failures?.includes?.('FAILURE_TYPE_TEMP') === false ? true : typeof raw?.value?.passive_infrared !== 'object' ? undefined : raw?.value?.passive_infrared?.faultInformation === undefined ? true : raw?.value?.passive_infrared?.faultInformation?.type === 'PASSIVE_INFRARED_FAULT_TYPE_NONE', }, nest: { fields: ['component_temp_test_passed'], translate: ({ raw }) => raw?.value?.component_temp_test_passed === true, }, }, co_test_passed: { google: { fields: ['carbon_monoxide'], translate: ({ raw }) => typeof raw?.value?.carbon_monoxide !== 'object' ? undefined : raw?.value?.carbon_monoxide?.faultInformation === undefined ? true : raw?.value?.carbon_monoxide?.faultInformation?.type === 'CO_FAULT_TYPE_NONE', }, nest: { fields: ['component_co_test_passed'], translate: ({ raw }) => raw?.value?.component_co_test_passed === true, }, }, latest_alarm_test: { google: { fields: ['self_test'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.self_test?.lastMstEnd?.seconds)) === true ? Number(raw.value.self_test.lastMstEnd.seconds) : undefined, }, nest: { fields: ['latest_manual_test_end_utc_secs'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.latest_manual_test_end_utc_secs)) === true ? Number(raw.value.latest_manual_test_end_utc_secs) : undefined, }, }, self_test_in_progress: { google: { fields: ['legacy_structure_self_test'], translate: ({ raw }) => raw?.value?.legacy_structure_self_test?.mstInProgress === true || raw?.value?.legacy_structure_self_test?.astInProgress === true, }, nest: { fields: ['structure_id'], related: ['manual_self_test_in_progress'], translate: ({ rawData, raw }) => rawData?.['safety.' + raw?.value?.structure_id]?.value?.manual_self_test_in_progress === true, }, }, replacement_date: { required: true, google: { fields: ['legacy_protect_device_settings'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.legacy_protect_device_settings?.replaceByDate?.seconds)) === true ? Number(raw.value.legacy_protect_device_settings.replaceByDate.seconds) : undefined, }, nest: { fields: ['replace_by_date_utc_secs'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.replace_by_date_utc_secs)) === true ? Number(raw.value.replace_by_date_utc_secs) : undefined, }, }, topaz_hush_key: { google: { fields: ['safety_structure_settings'], translate: ({ raw }) => typeof raw?.value?.safety_structure_settings?.structureHushKey === 'string' ? raw.value.safety_structure_settings.structureHushKey : '', }, nest: { fields: ['structure_id'], related: ['topaz_hush_key'], translate: ({ rawData, raw }) => typeof rawData?.['structure.' + raw?.value?.structure_id]?.value?.topaz_hush_key === 'string' ? rawData['structure.' + raw.value.structure_id].value.topaz_hush_key : '', }, }, detected_motion: { google: { fields: ['legacy_protect_device_info'], translate: ({ raw }) => typeof raw?.value?.legacy_protect_device_info === 'object' ? raw.value.legacy_protect_device_info.autoAway !== true : false, }, nest: { fields: ['auto_away'], translate: ({ raw }) => raw?.value?.auto_away === false, }, }, }; // Function to process our RAW Nest or Google data for protect devices export function processRawData(log, rawData, config, deviceType = undefined, changedData = undefined) { if ( rawData === null || typeof rawData !== 'object' || rawData?.constructor !== Object || typeof config !== 'object' || config?.constructor !== Object ) { return; } // Process data for any smoke detectors we have in the raw data let devices = {}; Object.entries(rawData) .filter( ([key, value]) => key.startsWith('topaz.') === true || (key.startsWith('DEVICE_') === true && PROTOBUF_RESOURCES.PROTECT.includes(value?.value?.device_info?.typeName) === true), ) .forEach(([object_key, value]) => { try { // Only process valid Google or Nest Protect resources if ( (value?.source !== DATA_SOURCE.GOOGLE && value?.source !== DATA_SOURCE.NEST) || (value?.source === DATA_SOURCE.GOOGLE && (value?.value?.configuration_done?.deviceReady !== true || rawData?.[value?.value?.device_info?.pairerId?.resourceId] === undefined)) || (value?.source === DATA_SOURCE.NEST && (rawData?.['where.' + value?.value?.structure_id] === undefined || rawData?.['safety.' + value?.value?.structure_id] === undefined || rawData?.['widget_track.' + value?.value?.thread_mac_address?.toUpperCase?.()] === undefined)) ) { return; } // Map raw device data into our normalised protect schema let mappedResult = buildMappedObject( PROTECT_FIELD_MAP, createMappingContext(rawData, object_key, { nest: value?.source === DATA_SOURCE.NEST ? value : undefined, google: value?.source === DATA_SOURCE.GOOGLE ? value : undefined, }), changedData instanceof Map ? changedData.get(object_key)?.fields : undefined, ); let serialNumber = mappedResult?.data?.serialNumber; let existingDevice = devices[serialNumber]; // If we have all required fields, build the full protect device data object if (mappedResult?.hasRequired === true) { let tempDevice = { type: DEVICE_TYPE.PROTECT, manufacturer: 'Nest', ...mappedResult.data, }; // Respect requested device type if specified if (deviceType !== undefined && (typeof deviceType !== 'string' || deviceType === '' || tempDevice.type !== deviceType)) { return; } // Check for any device or home configuration options that match this device // We'll use the serial number to match against device options, and the home uuid to match against home options let deviceOptions = config?.devices?.find( (device) => device?.serialNumber?.toUpperCase?.() === tempDevice?.serialNumber?.toUpperCase?.(), ); let homeOptions = config?.homes?.find( (home) => home?.nest_home_uuid?.toUpperCase?.() === tempDevice?.nest_google_home_uuid?.toUpperCase?.() || home?.google_home_uuid?.toUpperCase?.() === tempDevice?.nest_google_home_uuid?.toUpperCase?.(), ); // Insert any extra options we've read in from configuration file for this device tempDevice.eveHistory = deviceOptions?.eveHistory !== undefined ? deviceOptions.eveHistory === true : config?.options?.eveHistory === true; tempDevice.logMotionEvents = deviceOptions?.logMotionEvents !== undefined ? deviceOptions.logMotionEvents === true : config?.options?.logMotionEvents === false ? false : true; // Process additional exclusion details tempDevice.excluded = deviceOptions?.exclude === true || (deviceOptions?.exclude !== false && (homeOptions?.exclude === true || (homeOptions?.exclude !== false && config?.options?.exclude === true))); // Store full device // Full always overrides partial if present if (existingDevice?.full !== true) { devices[tempDevice.serialNumber] = { full: true, data: tempDevice, }; } } // Refresh existing device reference after potential full insert existingDevice = devices[serialNumber]; // Only store partial data if nothing has already been stored for this serial in this pass. // A later full payload will replace an earlier partial payload. if ( mappedResult?.hasRequired === false && serialNumber !== undefined && typeof mappedResult?.data === 'object' && mappedResult.data?.constructor === Object && Object.keys(mappedResult.data).length !== 0 && existingDevice === undefined ) { devices[serialNumber] = { full: false, data: mappedResult.data, }; } } catch (error) { log?.error?.('Error processing protect data for "%s": %s', object_key, String(error)); } }); return devices; }