homebridge-eufy-security
Version:
Control Eufy Security from homebridge.
247 lines • 11 kB
JavaScript
// 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