UNPKG

homebridge-nest-accfactory

Version:

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

548 lines (479 loc) 21.5 kB
// Nest × Yale Lock - HomeKit integration // Part of homebridge-nest-accfactory // // HomeKit accessory implementation for Nest × Yale Lock devices. // Provides secure lock control, battery monitoring, auto-relock configuration, // tamper reporting, and activity tracking using Google protobuf API data. // // Responsibilities: // - Expose lock state and control via HomeKit LockMechanism service // - Synchronise current and target lock state with HomeKit // - Support auto-relock timeout control // - Report tamper, fault, and battery status // - Record lock activity history for Eve Home integration // // Services: // - LockMechanism (primary service) // - Battery (hidden, for battery level and low battery alerts) // // Features: // - Real-time lock state synchronisation with HomeKit // - Remote lock and unlock control // - Configurable auto-relock timeout // - Lock action source tracking (physical, keypad, remote, voice, implicit) // - Tamper detection support // - Battery monitoring with low battery alerts // - Eve Home history integration // // Notes: // - Google protobuf API support only // - Nest REST API is not used for lock devices // - Lock state and actor details are normalised before presentation to HomeKit // // Data Translation: // - Raw Google lock data is mapped using LOCK_FIELD_MAP // - processRawData() builds device objects and applies configuration overrides // - Field mapping keeps upstream API changes separate from HomeKit presentation // // 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 { DATA_SOURCE, DEVICE_TYPE, PROTOBUF_RESOURCES, LOW_BATTERY_LEVEL } from '../consts.js'; export default class NestLock extends HomeKitDevice { static TYPE = DEVICE_TYPE.LOCK; static VERSION = '2026.04.26'; // Code version // Define lock bolt states static STATE = { JAMMED: 'jammed', LOCKING: 'locking', UNLOCKING: 'unlocking', LOCKED: 'locked', UNLOCKED: 'unlocked', UNKNOWN: 'unknown', }; static LAST_ACTION = { PHYSICAL: 'physical', KEYPAD: 'keypad', REMOTE: 'remote', IMPLICIT: 'implicit', VOICE: 'voice', }; lockService = undefined; batteryService = undefined; onAdd() { // Setup lock service if not already present on the accessory and link it to the Eve app if configured to do so this.lockService = this.addHKService(this.hap.Service.LockMechanism, '', 1, {}); this.lockService.setPrimaryService(); // Setup set characteristics this.addHKCharacteristic(this.lockService, this.hap.Characteristic.LockCurrentState, { onGet: () => { return this.#currentState(this.deviceData); }, }); this.addHKCharacteristic(this.lockService, this.hap.Characteristic.LockTargetState, { onSet: (value) => { if (value !== this.lockService.getCharacteristic(this.hap.Characteristic.LockTargetState).value) { let locked = value === this.hap.Characteristic.LockTargetState.SECURED; this.set({ uuid: this.deviceData.nest_google_device_uuid, bolt_lock: locked, }); this.lockService.updateCharacteristic(this.hap.Characteristic.LockTargetState, value); this?.log?.info?.('Setting lock on "%s" to "%s"', this.deviceData.description, locked ? 'Locked' : 'Unlocked'); } }, onGet: () => { return this.#targetState(this.deviceData); }, }); this.addHKCharacteristic(this.lockService, this.hap.Characteristic.LockManagementAutoSecurityTimeout, { props: { minValue: 0, maxValue: this.deviceData.max_auto_relock_duration, }, onSet: (value) => { value = Math.floor(value); // Make a round number if (value !== this.deviceData.auto_relock_duration) { this.set({ uuid: this.deviceData.nest_google_device_uuid, auto_relock_duration: value, }); this?.log?.info?.( 'Setting lock auto-relocking duration on "%s" to "%s"', this.deviceData.description, value !== 0 ? value + ' seconds' : 'Disabled', ); } }, onGet: () => { return this.deviceData.auto_relock_duration; }, }); this.addHKCharacteristic(this.lockService, this.hap.Characteristic.LockLastKnownAction, { onGet: () => { return this.#lastAction(this.deviceData); }, }); this.addHKCharacteristic(this.lockService, this.hap.Characteristic.StatusTampered, { onGet: () => { return this.deviceData.tampered === true ? this.hap.Characteristic.StatusTampered.TAMPERED : this.hap.Characteristic.StatusTampered.NOT_TAMPERED; }, }); // Setup battery service if not already present on the accessory this.batteryService = this.addHKService(this.hap.Service.Battery, '', 1); this.batteryService.setHiddenService(true); } onRemove() { this.accessory.removeService(this.lockService); this.accessory.removeService(this.batteryService); this.lockService = undefined; this.batteryService = undefined; } onUpdate(deviceData) { if (typeof deviceData !== 'object') { return; } // Update lock state this.lockService.updateCharacteristic(this.hap.Characteristic.LockCurrentState, this.#currentState(deviceData)); this.lockService.updateCharacteristic(this.hap.Characteristic.LockTargetState, this.#targetState(deviceData)); this.lockService.updateCharacteristic(this.hap.Characteristic.LockLastKnownAction, this.#lastAction(deviceData)); this.lockService.updateCharacteristic(this.hap.Characteristic.LockManagementAutoSecurityTimeout, deviceData.auto_relock_duration); // If device isn't online report in HomeKit this.lockService.updateCharacteristic( this.hap.Characteristic.StatusFault, deviceData.online === true ? this.hap.Characteristic.StatusFault.NO_FAULT : this.hap.Characteristic.StatusFault.GENERAL_FAULT, ); // Update battery level and status 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 tampered state this.lockService.updateCharacteristic( this.hap.Characteristic.StatusTampered, deviceData.tampered !== true ? this.hap.Characteristic.StatusTampered.NOT_TAMPERED : this.hap.Characteristic.StatusTampered.TAMPERED, ); // Log lock state changes if (deviceData.bolt_state === NestLock.STATE.LOCKED && this.deviceData.bolt_state === NestLock.STATE.UNLOCKED) { this?.log?.info?.('Lock locked on "%s" by %s', deviceData.description, deviceData.bolt_actor); } if (deviceData.bolt_state === NestLock.STATE.UNLOCKED && this.deviceData.bolt_state === NestLock.STATE.LOCKED) { this?.log?.warn?.('Lock unlocked on "%s" by %s', deviceData.description, deviceData.bolt_actor); } if (deviceData.bolt_state === NestLock.STATE.JAMMED && this.deviceData.bolt_state !== NestLock.STATE.JAMMED) { this?.log?.error?.('Lock jammed on "%s"', deviceData.description); } if (deviceData.bolt_state !== NestLock.STATE.JAMMED && this.deviceData.bolt_state === NestLock.STATE.JAMMED) { this?.log?.info?.('Lock unjammed on "%s"', deviceData.description); } // Log lock status to history only if changed to previous recording if (deviceData.bolt_state === NestLock.STATE.LOCKED || deviceData.bolt_state === NestLock.STATE.UNLOCKED) { this.history(this.lockService, { status: deviceData.bolt_state === NestLock.STATE.LOCKED ? 0 : 1, // 0 = locked, 1 = unlocked }); } } #currentState(deviceData) { return deviceData.bolt_state === NestLock.STATE.JAMMED ? this.hap.Characteristic.LockCurrentState.JAMMED : deviceData.bolt_state === NestLock.STATE.LOCKED || deviceData.bolt_state === NestLock.STATE.UNLOCKING ? this.hap.Characteristic.LockCurrentState.SECURED : deviceData.bolt_state === NestLock.STATE.UNLOCKED || deviceData.bolt_state === NestLock.STATE.LOCKING ? this.hap.Characteristic.LockCurrentState.UNSECURED : this.hap.Characteristic.LockCurrentState.UNKNOWN; } #targetState(deviceData) { return deviceData.bolt_state === NestLock.STATE.LOCKED || deviceData.bolt_state === NestLock.STATE.LOCKING ? this.hap.Characteristic.LockTargetState.SECURED : this.hap.Characteristic.LockTargetState.UNSECURED; } #lastAction(deviceData) { return deviceData.bolt_actor === NestLock.LAST_ACTION.PHYSICAL ? deviceData.bolt_state === NestLock.STATE.LOCKED || deviceData.bolt_state === NestLock.STATE.LOCKING ? this.hap.Characteristic.LockLastKnownAction.SECURED_PHYSICALLY : this.hap.Characteristic.LockLastKnownAction.UNSECURED_PHYSICALLY : deviceData.bolt_actor === NestLock.LAST_ACTION.KEYPAD ? deviceData.bolt_state === NestLock.STATE.LOCKED || deviceData.bolt_state === NestLock.STATE.LOCKING ? this.hap.Characteristic.LockLastKnownAction.SECURED_BY_KEYPAD : this.hap.Characteristic.LockLastKnownAction.UNSECURED_BY_KEYPAD : deviceData.bolt_actor === NestLock.LAST_ACTION.REMOTE || deviceData.bolt_actor === NestLock.LAST_ACTION.VOICE ? deviceData.bolt_state === NestLock.STATE.LOCKED || deviceData.bolt_state === NestLock.STATE.LOCKING ? this.hap.Characteristic.LockLastKnownAction.SECURED_REMOTELY : this.hap.Characteristic.LockLastKnownAction.UNSECURED_REMOTELY : this.hap.Characteristic.LockLastKnownAction.UNSECURED; // Fallback } } // Lock field translation map // Maps raw source data -> normalised lock 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 LOCK_FIELD_MAP = { // Identity fields serialNumber: { required: true, google: { fields: ['device_identity'], translate: ({ raw }) => typeof raw?.value?.device_identity?.serialNumber === 'string' && raw.value.device_identity.serialNumber.trim() !== '' ? raw.value.device_identity.serialNumber.trim().toUpperCase() : undefined, }, }, nest_google_device_uuid: { required: true, google: { fields: [], translate: ({ objectKey }) => objectKey, }, }, nest_google_home_uuid: { google: { fields: ['device_info'], translate: ({ raw }) => raw?.value?.device_info?.pairerId?.resourceId, }, }, // Naming / descriptive fields 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, }, }, translate: ({ rawData, raw }) => { let description = String(raw?.value?.label?.label ?? '').trim(); let wheres = [ ...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 || {}), ]; let findWhere = (id) => String(wheres.find((where) => where?.whereId?.resourceId === id)?.label?.literal ?? '').trim(); let whereLocation = String(raw?.value?.device_located_settings?.whereLabel?.literal ?? '').trim(); if (whereLocation === '') { whereLocation = findWhere(raw?.value?.device_located_settings?.whereAnnotationRid?.resourceId); } let fixtureLocation = String(raw?.value?.device_located_settings?.fixtureNameLabel?.literal ?? '').trim(); if (fixtureLocation === '') { fixtureLocation = findWhere(raw?.value?.device_located_settings?.fixtureAnnotationRid?.resourceId); } // Prefer fixture (door) as primary location let location = fixtureLocation !== '' ? fixtureLocation : whereLocation; // Avoid duplication if (description.toUpperCase() === location.toUpperCase()) { location = ''; } // If description missing, use best location if (description === '' && location !== '') { description = location; location = ''; } if (description === '' && location === '') { description = 'unknown description'; } // If both exist and different -> include both (door + room) if (location !== '' && whereLocation !== '' && fixtureLocation !== '' && fixtureLocation !== whereLocation) { return HomeKitDevice.makeValidHKName(description + ' - ' + fixtureLocation + ' (' + whereLocation + ')'); } return HomeKitDevice.makeValidHKName(location === '' ? description : description + ' - ' + location); }, // Core operational fields online: { required: true, google: { fields: ['liveness'], translate: ({ raw }) => raw?.value?.liveness?.status === 'LIVENESS_DEVICE_STATUS_ONLINE', }, }, tampered: { required: true, google: { fields: ['tamper'], translate: ({ raw }) => raw?.value?.tamper?.tamperState === 'TAMPER_STATE_TAMPERED', }, }, bolt_state: { required: true, google: { fields: ['bolt_lock'], translate: ({ raw }) => raw?.value?.bolt_lock?.actuatorState?.startsWith?.('BOLT_ACTUATOR_STATE_JAMMED') === true ? NestLock.STATE.JAMMED : raw?.value?.bolt_lock?.actuatorState === 'BOLT_ACTUATOR_STATE_LOCKING' ? NestLock.STATE.LOCKING : raw?.value?.bolt_lock?.actuatorState === 'BOLT_ACTUATOR_STATE_UNLOCKING' ? NestLock.STATE.UNLOCKING : raw?.value?.bolt_lock?.lockedState === 'BOLT_LOCKED_STATE_LOCKED' ? NestLock.STATE.LOCKED : raw?.value?.bolt_lock?.lockedState === 'BOLT_LOCKED_STATE_UNLOCKED' ? NestLock.STATE.UNLOCKED : NestLock.STATE.UNKNOWN, }, }, bolt_actor: { required: true, google: { fields: ['bolt_lock'], translate: ({ raw }) => raw?.value?.bolt_lock?.boltLockActor?.method === 'BOLT_LOCK_ACTOR_METHOD_PHYSICAL' ? NestLock.LAST_ACTION.PHYSICAL : raw?.value?.bolt_lock?.boltLockActor?.method === 'BOLT_LOCK_ACTOR_METHOD_KEYPAD_PIN' ? NestLock.LAST_ACTION.KEYPAD : [ 'BOLT_LOCK_ACTOR_METHOD_REMOTE_USER_EXPLICIT', 'BOLT_LOCK_ACTOR_METHOD_REMOTE_USER_IMPLICIT', 'BOLT_LOCK_ACTOR_METHOD_REMOTE_USER_OTHER', 'BOLT_LOCK_ACTOR_METHOD_REMOTE_DELEGATE', ].includes(raw?.value?.bolt_lock?.boltLockActor?.method) === true ? NestLock.LAST_ACTION.REMOTE : raw?.value?.bolt_lock?.boltLockActor?.method === 'BOLT_LOCK_ACTOR_METHOD_VOICE_ASSISTANT' ? NestLock.LAST_ACTION.VOICE : ['BOLT_LOCK_ACTOR_METHOD_LOCAL_IMPLICIT', 'BOLT_LOCK_ACTOR_METHOD_LOW_POWER_SHUTDOWN'].includes( raw?.value?.bolt_lock?.boltLockActor?.method, ) === true ? NestLock.LAST_ACTION.IMPLICIT : NestLock.LAST_ACTION.PHYSICAL, }, }, battery_level: { required: true, google: { fields: ['battery_power_source'], translate: ({ raw }) => // Google API reports lock battery remaining as fractional percentage (0–1) Number.isFinite(Number(raw?.value?.battery_power_source?.remaining?.remainingPercent?.value)) === true ? Math.round(scaleValue(Number(raw.value.battery_power_source.remaining.remainingPercent.value), 0, 1, 0, 100)) : undefined, }, }, // Optional configuration/state fields auto_relock_duration: { required: true, google: { fields: ['bolt_lock_settings'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.bolt_lock_settings?.autoRelockDuration?.seconds)) === true ? Number(raw.value.bolt_lock_settings.autoRelockDuration.seconds) : undefined, }, }, max_auto_relock_duration: { required: true, google: { fields: ['bolt_lock_capabilities'], translate: ({ raw }) => Number.isFinite(Number(raw?.value?.bolt_lock_capabilities?.maxAutoRelockDuration?.seconds)) === true ? Number(raw.value.bolt_lock_capabilities.maxAutoRelockDuration.seconds) : undefined, }, }, }; // Function to process our RAW Google data for lock devices // eslint-disable-next-line no-unused-vars 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 lock(s) we have in the raw data let devices = {}; Object.entries(rawData) .filter( ([key, value]) => key.startsWith('DEVICE_') === true && PROTOBUF_RESOURCES.LOCK.includes(value?.value?.device_info?.typeName) === true, ) .forEach(([object_key, value]) => { try { // Only process valid Google lock devices that are ready and linked to a known home object if ( value?.source !== DATA_SOURCE.GOOGLE || value?.value?.configuration_done?.deviceReady !== true || rawData?.[value?.value?.device_info?.pairerId?.resourceId] === undefined ) { return; } // Map raw device data into our normalised lock schema let mappedResult = buildMappedObject( LOCK_FIELD_MAP, createMappingContext(rawData, object_key, { google: value, }), 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 lock device data object if (mappedResult?.hasRequired === true) { let tempDevice = { type: DEVICE_TYPE.LOCK, model: 'x Yale Lock', manufacturer: 'Nest', ...mappedResult.data, }; // 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; // 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 lock data for "%s": %s', object_key, String(error)); } }); return devices; }