UNPKG

homebridge

Version:
297 lines • 12.8 kB
/** * State Manager * * Handles accessory state updates, state retrieval, command triggering, * and state change notifications. */ import { Logger } from '../../logger.js'; import { MatterDeviceError } from '../types.js'; const log = Logger.withPrefix('Matter/Server'); /** * Recursively convert bigint values to numbers. matter.js can surface int64 * attributes (e.g. ElectricalPowerMeasurement.activePower, energy readings) * as bigint, which JSON.stringify cannot serialize - and state read results * cross the child-bridge IPC boundary as JSON. Energy values stay far below * Number.MAX_SAFE_INTEGER (2^53 mWh is around nine billion kWh), so the * conversion is lossless in practice. */ function coerceBigintsToNumbers(value) { if (typeof value === 'bigint') { return Number(value); } if (Array.isArray(value)) { return value.map(coerceBigintsToNumbers); } // Only recurse into plain data objects (cluster structs). Anything with a // prototype - Uint8Array byte strings, Dates, class instances - must pass // through untouched or the copy would mangle it. if (value && typeof value === 'object') { const proto = Object.getPrototypeOf(value); if (proto !== Object.prototype && proto !== null) { return value; } const result = {}; for (const [key, entry] of Object.entries(value)) { result[key] = coerceBigintsToNumbers(entry); } return result; } return value; } /** * Maps the four ElectricalEnergyMeasurement reading attributes to the shape * matter.js setMeasurement() expects. */ const ENERGY_ATTRIBUTE_MAP = { cumulativeEnergyImported: { period: 'cumulativeEnergy', direction: 'imported' }, cumulativeEnergyExported: { period: 'cumulativeEnergy', direction: 'exported' }, periodicEnergyImported: { period: 'periodicEnergy', direction: 'imported' }, periodicEnergyExported: { period: 'periodicEnergy', direction: 'exported' }, }; export class StateManager { accessories; emitter; getMonitoringEnabled; constructor(accessories, emitter, getMonitoringEnabled) { this.accessories = accessories; this.emitter = emitter; this.getMonitoringEnabled = getMonitoringEnabled; } /** * Update the state of a Matter accessory (Plugin API) */ async updateAccessoryState(uuid, cluster, attributes, partId) { const accessory = this.accessories.get(uuid); if (!accessory) { throw new MatterDeviceError(`Accessory ${uuid} not found or not registered`); } // Plugins may pass bigint for int64 attributes (e.g. energy readings). // Coerce once at entry: the values also land in accessory.clusters, which // is JSON-serialized by the accessory cache and the UI IPC channel - // JSON.stringify throws on bigint. matter.js accepts number for int64. attributes = coerceBigintsToNumbers(attributes); let targetEndpoint; let targetClusters; let displayName; if (partId) { const part = accessory._parts?.find(p => p.id === partId); if (!part || !part.endpoint) { throw new MatterDeviceError(`Part ${partId} not found in accessory ${uuid}`); } targetEndpoint = part.endpoint; targetClusters = part.clusters; displayName = part.displayName || `${accessory.displayName} - ${partId}`; } else { if (!accessory.endpoint) { throw new MatterDeviceError(`Accessory ${uuid} not registered or missing endpoint`); } targetEndpoint = accessory.endpoint; targetClusters = accessory.clusters; displayName = accessory.displayName; } // Defer the update to avoid "read-only transaction" errors when called from handlers return new Promise((resolve, reject) => { setImmediate(async () => { try { if (cluster === 'electricalEnergyMeasurement') { // Energy readings must go through matter.js setMeasurement() so the // spec-required CumulativeEnergyMeasured / PeriodicEnergyMeasured // events are emitted alongside the attribute updates. await this.applyEnergyMeasurementUpdate(targetEndpoint, attributes); } else { const updateObject = { [cluster]: attributes }; await targetEndpoint.set(updateObject); } // Update cached clusters object for persistence if (!targetClusters) { log.warn(`Target clusters undefined for ${displayName}, cannot cache state`); } else { if (!targetClusters[cluster]) { targetClusters[cluster] = {}; } targetClusters[cluster] = { ...targetClusters[cluster], ...attributes, }; } const partInfo = partId ? ` (part: ${partId})` : ''; log.debug(`Updated ${cluster} state for ${displayName}${partInfo}:`, attributes); this.notifyStateChange(uuid, cluster, attributes, partId); resolve(); } catch (error) { const partInfo = partId ? ` part ${partId}` : ''; log.error(`Failed to update state for accessory ${uuid}${partInfo}:`, error); reject(new MatterDeviceError(`Failed to update accessory state: ${error}`)); } }); }); } /** * Apply an ElectricalEnergyMeasurement update. Non-null energy readings are * routed through the matter.js setMeasurement() helper, which updates the * attributes and also emits the CumulativeEnergyMeasured / * PeriodicEnergyMeasured events the spec requires. Everything else (accuracy, * null clears) goes through a plain state set. */ async applyEnergyMeasurementUpdate(endpoint, attributes) { const behaviorClass = endpoint.behaviors?.supported?.electricalEnergyMeasurement; if (!behaviorClass) { // No EEM behavior on this endpoint - let the plain set produce the // same "unsupported cluster" error any other cluster would. await endpoint.set({ electricalEnergyMeasurement: attributes }); return; } const measurement = {}; const rest = {}; for (const [key, value] of Object.entries(attributes)) { const mapping = ENERGY_ATTRIBUTE_MAP[key]; if (mapping && value !== null && value !== undefined) { measurement[mapping.period] = { ...measurement[mapping.period], [mapping.direction]: value }; } else { rest[key] = value; } } if (Object.keys(rest).length > 0) { await endpoint.set({ electricalEnergyMeasurement: rest }); } if (Object.keys(measurement).length > 0) { await endpoint.act((agent) => agent.get(behaviorClass).setMeasurement(measurement)); } } /** * Get a Matter accessory's current state */ getAccessoryState(uuid, cluster, partId) { const accessory = this.accessories.get(uuid); if (!accessory) { log.debug(`Accessory ${uuid} not found`); return undefined; } let targetEndpoint; let displayName; if (partId) { const part = accessory._parts?.find(p => p.id === partId); if (!part || !part.endpoint) { log.debug(`Part ${partId} not found in accessory ${uuid}`); return undefined; } targetEndpoint = part.endpoint; displayName = part.displayName || `${accessory.displayName} - ${partId}`; } else { if (!accessory.endpoint) { log.debug(`Accessory ${uuid} not registered or missing endpoint`); return undefined; } targetEndpoint = accessory.endpoint; displayName = accessory.displayName; } try { if (!targetEndpoint.state) { log.debug(`endpoint.state is undefined for ${displayName}`); return undefined; } if (!targetEndpoint.state[cluster]) { const availableClusters = Object.keys(targetEndpoint.state || {}); log.debug(`Cluster '${cluster}' not found on ${displayName}. Available: ${availableClusters.join(', ')}`); return undefined; } const clusterState = targetEndpoint.state[cluster]; const result = {}; const allKeys = new Set([ ...Object.keys(clusterState), ...Object.getOwnPropertyNames(clusterState), ]); for (const key of allKeys) { try { if (key.startsWith('_') || key.startsWith('$')) { continue; } const value = clusterState[key]; if (typeof value === 'function' || value === undefined) { continue; } result[key] = coerceBigintsToNumbers(value); } catch (propError) { log.debug(`Could not read property ${key} from ${cluster}:`, propError); } } if (Object.keys(result).length === 0) { log.debug(`Cluster ${cluster} found but no readable properties on accessory ${accessory.displayName}`); return undefined; } return result; } catch (error) { log.error(`Failed to get state for accessory ${uuid}:`, error); return undefined; } } /** * Trigger a command on a Matter accessory */ async triggerCommand(uuid, cluster, command, args, partId) { const accessory = this.accessories.get(uuid); if (!accessory) { throw new MatterDeviceError(`Accessory ${uuid} not found or not registered`); } let targetEndpoint; let displayName; if (partId) { const part = accessory._parts?.find(p => p.id === partId); if (!part || !part.endpoint) { throw new MatterDeviceError(`Part ${partId} not found in accessory ${uuid}`); } targetEndpoint = part.endpoint; displayName = part.displayName || `${accessory.displayName} - ${partId}`; } else { if (!accessory.endpoint) { throw new MatterDeviceError(`Accessory ${uuid} not registered or missing endpoint`); } targetEndpoint = accessory.endpoint; displayName = accessory.displayName; } try { const partInfo = partId ? ` (part: ${partId})` : ''; log.debug(`Triggering command ${cluster}.${command} for ${displayName}${partInfo}`, args); await targetEndpoint.act((agent) => { const clusterBehavior = agent[cluster]; if (!clusterBehavior) { throw new Error(`Cluster '${cluster}' not found on endpoint`); } if (typeof clusterBehavior[command] !== 'function') { throw new TypeError(`Command '${command}' not found on cluster '${cluster}'`); } if (args && Object.keys(args).length > 0) { return clusterBehavior[command](args); } else { return clusterBehavior[command](); } }); log.debug(`Command ${cluster}.${command} succeeded for ${displayName}${partInfo}`); } catch (error) { const partInfo = partId ? ` part ${partId}` : ''; log.error(`Failed to trigger command for accessory ${uuid}${partInfo}:`, error); throw new MatterDeviceError(`Failed to trigger command: ${error}`); } } /** * Notify that an accessory's state has changed */ notifyStateChange(uuid, cluster, state, partId) { if (!this.getMonitoringEnabled()) { return; } this.emitter.emit('stateChange', { uuid, cluster, state, partId }); } } //# sourceMappingURL=StateManager.js.map