UNPKG

homebridge-eufy-security

Version:
247 lines 11 kB
// Servicing and caching strategy inspired // by homebridge-ring — https://github.com/dgreif/ring import { DeviceType } from 'eufy-security-client'; import { EventEmitter } from 'events'; import { CHAR, SERV, log } from '../utils/utils.js'; /** * Determine if the serviceType is an instance of Service. * * @param {WithUUID<typeof Service> | Service} serviceType - The service type to be checked. * @returns {boolean} Returns true if the serviceType is an instance of Service, otherwise false. */ function isServiceInstance(serviceType) { return typeof serviceType === 'object'; } export class BaseAccessory extends EventEmitter { platform; accessory; device; servicesInUse; SN; name; log; /** * Tracks characteristics with getValue for cache-based updates. * Values are seeded once at registration and refreshed via * the device's 'property changed' event (push-based). */ registeredGetters = []; constructor(platform, accessory, device) { super(); this.platform = platform; this.accessory = accessory; this.device = device; this.device = device; // Share servicesInUse across all BaseAccessory instances on the same // PlatformAccessory so that pruneUnusedServices() won't remove services // registered by a sibling accessor (e.g. LockAccessory + CameraAccessory // on combo devices like the T8530). if (!accessory._servicesInUse) { accessory._servicesInUse = []; } this.servicesInUse = accessory._servicesInUse; this.SN = this.device.getSerial(); this.name = this.device.getName(); this.log = log.getSubLogger({ name: '', prefix: [this.name], }); this.registerCharacteristic({ serviceType: SERV.AccessoryInformation, characteristicType: CHAR.Manufacturer, getValue: () => 'Eufy', }); this.registerCharacteristic({ serviceType: SERV.AccessoryInformation, characteristicType: CHAR.Name, getValue: () => this.name || 'Unknowm', }); this.registerCharacteristic({ serviceType: SERV.AccessoryInformation, characteristicType: CHAR.Model, getValue: () => DeviceType[this.device.getDeviceType()] || 'Unknowm', }); this.registerCharacteristic({ serviceType: SERV.AccessoryInformation, characteristicType: CHAR.SerialNumber, getValue: () => this.SN || 'Unknowm', }); this.registerCharacteristic({ serviceType: SERV.AccessoryInformation, characteristicType: CHAR.FirmwareRevision, getValue: () => this.device.getSoftwareVersion() || 'Unknowm', }); this.registerCharacteristic({ serviceType: SERV.AccessoryInformation, characteristicType: CHAR.HardwareRevision, getValue: () => this.device.getHardwareVersion() || 'Unknowm', }); // Cameras accumulate many listeners (property changes, events, snapshots, streaming). // Raise limit to prevent MaxListenersExceededWarning in Node 22+. if (typeof this.device.setMaxListeners === 'function') { this.device.setMaxListeners(30); } if (this.platform.config.enableDetailedLogging) { this.device.on('raw property changed', this.handleRawPropertyChange.bind(this)); this.device.on('property changed', this.handlePropertyChange.bind(this)); } // Refresh cached characteristic values on any device property change. // This keeps all getValue-based characteristics up-to-date via push // without requiring HomeKit to poll through onGet. this.device.on('property changed', this.refreshCachedValues.bind(this)); this.logPropertyKeys(); } // Function to extract and log keys logPropertyKeys() { this.log.debug(`Property Keys:`, this.device.getProperties()); } /** * Re-evaluate every registered getValue and push updates to HomeKit * only when the returned value has actually changed. * Triggered by the device's 'property changed' event. */ refreshCachedValues() { for (const reg of this.registeredGetters) { try { const newValue = reg.getValue(undefined, reg.characteristic, reg.service); if (newValue !== reg.lastValue) { reg.lastValue = newValue; reg.characteristic.updateValue(newValue); this.log.debug(`CACHE '${reg.serviceTypeName} / ${reg.characteristicTypeName}':`, newValue); } } catch { // silently ignore errors during cache refresh } } } handleRawPropertyChange(device, type, value) { this.log.debug(`Raw Property Changes:`, type, value); } handlePropertyChange(device, name, value) { this.log.debug(`Property Changes:`, name, value); } /** * Register characteristics for a given Homebridge service. * * This method handles the registration of Homebridge characteristics. * It includes optional features like value debouncing and event triggers. * * @param {Object} params - Parameters needed for registering characteristics. */ registerCharacteristic({ characteristicType, serviceType, getValue, setValue, onValue, onSimpleValue, onMultipleValue, name, serviceSubType, setValueDebounceTime = 0, }) { this.log.debug(`REGISTER CHARACTERISTIC ${serviceType.name} / ${characteristicType.name} / ${name}`); const service = this.getService(serviceType, name, serviceSubType); const characteristic = service.getCharacteristic(characteristicType); this.log.debug(`REGISTER CHARACTERISTIC (${service.UUID}) / (${characteristic.UUID})`); if (getValue) { // Seed initial value and track for property-change refresh. // No onGet handler is registered — HomeKit uses the value set by // updateValue(), making polls (every ~10s) zero-cost. // Fresh values are pushed via 'property changed', onSimpleValue, // onValue, and onMultipleValue events. let initialValue; try { initialValue = getValue(undefined, characteristic, service); this.log.debug(`SEED '${serviceType.name} / ${characteristicType.name}':`, initialValue); } catch (e) { this.log.debug(`SEED FAIL '${serviceType.name} / ${characteristicType.name}':`, e); } this.registeredGetters.push({ getValue, characteristic, service, serviceTypeName: serviceType.name || 'unknown', characteristicTypeName: characteristicType.name || 'unknown', lastValue: initialValue, }); if (initialValue !== undefined && initialValue !== null) { characteristic.updateValue(initialValue); } } if (setValue && setValueDebounceTime) { let timeoutId = null; characteristic.onSet(async (value) => { if (timeoutId) { clearTimeout(timeoutId); } timeoutId = setTimeout(() => { timeoutId = null; setValue(value, characteristic, service); }, setValueDebounceTime); }); } else if (setValue) { characteristic.onSet(async (value) => { Promise.resolve(setValue(value, characteristic, service)); }); } if (onSimpleValue) { this.device.on(onSimpleValue, (device, value) => { this.log.info(`ON '${serviceType.name} / ${characteristicType.name} / ${onSimpleValue}':`, value); characteristic.updateValue(value); }); } if (onValue) { this.log.debug(`ON '${serviceType.name} / ${characteristicType.name}'`); onValue(service, characteristic); } if (onMultipleValue) { // Attach the common event handler to each event type onMultipleValue.forEach(eventType => { this.device.on(eventType, (device, value) => { this.log.info(`ON '${serviceType.name} / ${characteristicType.name} / ${eventType}':`, value); characteristic.updateValue(value); }); }); } } /** * Retrieve an existing service or create a new one if it doesn't exist. * * @param {ServiceType} serviceType - The type of service to retrieve or create. * @param {string} [name] - The name of the service (optional). * @param {string} [subType] - The subtype of the service (optional). * @returns {Service} Returns the existing or newly created service. * @throws Will throw an error if there are overlapping services. */ getService(serviceType, name = this.name, subType) { if (isServiceInstance(serviceType)) { return serviceType; } const existingService = subType ? this.accessory.getServiceById(serviceType, subType) : this.accessory.getService(serviceType); const service = existingService || this.accessory.addService(serviceType, name, subType); if (existingService && existingService.displayName && name !== existingService.displayName) { throw new Error(`Overlapping services for device ${this.name} - ${name} != ${existingService.displayName} - ${serviceType}`); } if (!this.servicesInUse.includes(service)) { this.servicesInUse.push(service); } return service; } pruneUnusedServices() { // Services managed by CameraController must never be pruned. // CameraController creates these automatically during configureController() // and they are not tracked in servicesInUse. const safeServiceUUIDs = [ SERV.CameraRTPStreamManagement.UUID, SERV.CameraOperatingMode.UUID, SERV.CameraRecordingManagement.UUID, SERV.DataStreamTransportManagement.UUID, SERV.Microphone.UUID, SERV.Speaker.UUID, ]; this.accessory.services.forEach((service) => { if (!this.servicesInUse.includes(service) && !safeServiceUUIDs.includes(service.UUID)) { this.log.debug(`Pruning unused service ${service.UUID} ${service.displayName || service.name}`); this.accessory.removeService(service); } }); } } //# sourceMappingURL=BaseAccessory.js.map