UNPKG

homebridge-config-ui-x

Version:

A web based management, configuration and control platform for Homebridge.

594 lines • 25.6 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; import { join } from 'node:path'; import { HapClient } from '@homebridge/hap-client'; import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { mkdirp, pathExists, readJson } from 'fs-extra/esm'; import NodeCache from 'node-cache'; import { ConfigService } from '../../core/config/config.service.js'; import { JsonFileStoreService } from '../../core/fs/json-file-store.service.js'; import { HomebridgeIpcService } from '../../core/homebridge-ipc/homebridge-ipc.service.js'; import { Logger } from '../../core/logger/logger.service.js'; let AccessoriesService = class AccessoriesService { configService; logger; homebridgeIpcService; jsonStore; hapClient; accessoriesCache = new NodeCache({ stdTTL: 0 }); matterMonitoringActive = false; matterUpdateListener = null; matterMonitoringStartPromise = null; activeClients = new Set(); clientSessions = new Map(); matterAccessories = []; matterRequests = new Map(); matterDispatcherInstalled = false; constructor(configService, logger, homebridgeIpcService, jsonStore) { this.configService = configService; this.logger = logger; this.homebridgeIpcService = homebridgeIpcService; this.jsonStore = jsonStore; if (this.configService.homebridgeInsecureMode) { this.hapClient = new HapClient({ pin: this.configService.homebridgeConfig.bridge.pin, logger: this.logger, config: this.configService.ui.accessoryControl || {}, }); } } async connect(client) { if (!this.configService.homebridgeInsecureMode) { this.logger.error('Homebridge must be running in insecure mode to control accessories.'); return; } if (this.activeClients.has(client)) { await this.clientSessions.get(client)?.reload(); return; } this.activeClients.add(client); let services; let disconnected = false; const loadAllAccessories = async (refresh) => { if (disconnected) { return; } if (!refresh) { const cached = this.accessoriesCache.get('services'); if (cached && cached.length) { client.emit('accessories-data', cached); } } const hapServices = await this.loadAccessories(); if (disconnected) { return; } this.refreshCharacteristics(hapServices); client.emit('hap-accessories-ready-for-control'); client.emit('accessories-data', hapServices); const matterServices = await this.loadMatterAccessories(); if (disconnected) { return; } client.emit('matter-accessories-ready-for-control'); if (matterServices.length > 0) { client.emit('accessories-data', matterServices); } services = [...hapServices, ...matterServices]; this.accessoriesCache.set('services', services); }; this.clientSessions.set(client, { reload: () => loadAllAccessories(true) }); await this.ensureMatterMonitoringStarted(); await loadAllAccessories(false); const requestHandler = async (msg) => { if (msg.refresh) { await loadAllAccessories(true); } else if (msg.set) { if (msg.set.uniqueId && msg.set.uniqueId.startsWith('matter:')) { if (msg.set.cluster && msg.set.attributes) { await this.handleMatterControl(client, { uniqueId: msg.set.uniqueId, cluster: msg.set.cluster, attributes: msg.set.attributes, }); } } else { const service = services.find(x => x.uniqueId === msg.set.uniqueId); if (service && 'serviceCharacteristics' in service) { try { await service.setCharacteristic(msg.set.iid, msg.set.value); const hapServices = await this.loadAccessories(); setTimeout(() => { this.refreshCharacteristics(hapServices); }, 1500); services = [...hapServices, ...this.matterAccessories]; } catch (e) { client.emit('accessory-control-failure', e.message); } } } } }; client.on('accessory-control', requestHandler); const monitor = await this.hapClient.monitorCharacteristics(); const updateHandler = (data) => { client.emit('accessories-data', data); }; monitor.on('service-update', updateHandler); const instanceUpdateHandler = async () => { client.emit('accessories-reload-required', services); }; this.hapClient.on('instance-discovered', instanceUpdateHandler); const secondaryLoadTimeout = setTimeout(async () => { await loadAllAccessories(true); }, 3000); const onEnd = () => { disconnected = true; clearTimeout(secondaryLoadTimeout); this.clientSessions.delete(client); client.removeAllListeners('end'); client.removeAllListeners('disconnect'); client.removeAllListeners('accessory-control'); monitor.removeAllListeners('service-update'); monitor.finish(); this.hapClient.removeListener('instance-discovered', instanceUpdateHandler); this.activeClients.delete(client); }; client.on('disconnect', onEnd.bind(this)); client.on('end', onEnd.bind(this)); this.hapClient.refreshInstances(); } refreshCharacteristics(services) { Promise.all(services.map(service => service.refreshCharacteristics().catch((error) => { this.logger.error(`Failed to refresh characteristics for service ${service.uniqueId}: ${error.message}`); }))).catch((error) => { this.logger.warn(`Failed to refresh characteristics: ${error.message}`); }); } async loadAccessories() { if (!this.configService.homebridgeInsecureMode) { throw new BadRequestException('Homebridge must be running in insecure mode to access accessories.'); } try { return await this.hapClient.getAllServices(); } catch (e) { if (e.response?.status === 401) { this.logger.warn('Homebridge must be running in insecure mode to view and control accessories from this plugin.'); } else { this.logger.error(`Failed to load accessories from Homebridge as ${e.message}.`); } return []; } } async getAccessory(uniqueId) { if (uniqueId.startsWith('matter:')) { return this.getMatterAccessory(uniqueId); } const services = await this.loadAccessories(); const service = services.find(x => x.uniqueId === uniqueId); if (!service) { throw new BadRequestException(`Service with uniqueId of '${uniqueId}' not found.`); } try { await service.refreshCharacteristics(); return service; } catch (e) { throw new BadRequestException(e.message); } } async getMatterAccessory(uniqueId) { try { const { uuid, partId } = this.parseMatterUniqueId(uniqueId); const response = await this.waitForMatterEvent('accessoryInfo', (correlationId) => { this.homebridgeIpcService.sendMessage('getMatterAccessoryInfo', { uuid, correlationId }); }); if (response.error) { throw new BadRequestException(response.error); } if (partId) { const part = response.parts?.find((p) => p.id === partId); if (part) { return this.transformMatterAccessory(response, part); } throw new BadRequestException(`Part '${partId}' not found in accessory`); } return this.transformMatterAccessory(response); } catch (error) { this.logger.error(`Failed to get Matter accessory info for ${uniqueId}:`, error); throw new BadRequestException(error.message || 'Failed to get Matter accessory info'); } } async setAccessoryCharacteristic(uniqueId, characteristicType, value) { const services = await this.loadAccessories(); const service = services.find(x => x.uniqueId === uniqueId); if (!service) { throw new BadRequestException(`Service with uniqueId of '${uniqueId}' not found.`); } const characteristic = service.getCharacteristic(characteristicType); if (!characteristic || !characteristic.canWrite) { const types = service.serviceCharacteristics.filter(x => x.canWrite).map(x => `'${x.type}'`).join(', '); throw new BadRequestException(`Invalid characteristicType. Valid types are: ${types}.`); } if (['uint8', 'uint16', 'uint32', 'uint64'].includes(characteristic.format)) { value = Number.parseInt(value, 10); if (characteristic.minValue !== undefined && value < characteristic.minValue) { throw new BadRequestException(`Invalid value. The value must be between ${characteristic.minValue} and ${characteristic.maxValue}.`); } if (characteristic.maxValue !== undefined && value > characteristic.maxValue) { throw new BadRequestException(`Invalid value. The value must be between ${characteristic.minValue} and ${characteristic.maxValue}.`); } } if (characteristic.format === 'float') { value = Number.parseFloat(value); if (characteristic.minValue !== undefined && value < characteristic.minValue) { throw new BadRequestException(`Invalid value. The value must be between ${characteristic.minValue} and ${characteristic.maxValue}.`); } if (characteristic.maxValue !== undefined && value > characteristic.maxValue) { throw new BadRequestException(`Invalid value. The value must be between ${characteristic.minValue} and ${characteristic.maxValue}.`); } } if (characteristic.format === 'bool') { if (typeof value === 'string') { if (['true', '1'].includes(value.toLowerCase())) { value = true; } else if (['false', '0'].includes(value.toLowerCase())) { value = false; } } else if (typeof value === 'number') { value = value === 1; } if (typeof value !== 'boolean') { throw new BadRequestException('Invalid value. The value must be a boolean (true or false).'); } } try { await characteristic.setValue(value); await service.refreshCharacteristics(); return service; } catch (e) { throw new BadRequestException(e.message); } } async getAccessoryLayout(username) { try { const accessoryLayout = await readJson(this.configService.accessoryLayoutPath); if (username in accessoryLayout) { return accessoryLayout[username]; } else { throw new Error('User not in Accessory Layout'); } } catch (e) { return [ { name: 'Default Room', isDefault: true, services: [], }, ]; } } async saveAccessoryLayout(user, layout) { if (!await pathExists(join(this.configService.storagePath, 'accessories'))) { await mkdirp(join(this.configService.storagePath, 'accessories')); } await this.jsonStore.mutate(this.configService.accessoryLayoutPath, (current) => { const accessoryLayout = current ?? {}; accessoryLayout[user] = layout; return accessoryLayout; }); this.logger.log(`Accessory layout changes saved for ${user}.`); return layout; } resetInstancePool() { if (this.configService.homebridgeInsecureMode) { this.hapClient.resetInstancePool(); } } parseMatterUniqueId(uniqueId) { const parts = uniqueId.replace('matter:', '').split(':'); return { uuid: parts[0], partId: parts[1], }; } buildMatterUniqueId(uuid, partId) { return partId ? `matter:${uuid}:${partId}` : `matter:${uuid}`; } async waitForMatterEvent(eventType, sendRequest) { try { return await this.attemptMatterEvent(eventType, sendRequest, 10000); } catch { this.logger.warn(`Matter IPC request '${eventType}' timed out, retrying...`); try { return await this.attemptMatterEvent(eventType, sendRequest, 10000); } catch (retryError) { this.logger.error(`Matter IPC request '${eventType}' failed after retry`); throw retryError; } } } async attemptMatterEvent(eventType, sendRequest, timeoutMs) { this.ensureMatterDispatcher(); const correlationId = `${eventType}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; return new Promise((resolve, reject) => { const timer = setTimeout(() => { if (this.matterRequests.delete(correlationId)) { reject(new Error('The Homebridge service did not respond')); } }, timeoutMs); this.matterRequests.set(correlationId, { eventType, resolve, reject, timer, }); try { sendRequest(correlationId); } catch (e) { if (this.matterRequests.delete(correlationId)) { clearTimeout(timer); reject(e); } } }); } ensureMatterDispatcher() { if (this.matterDispatcherInstalled) { return; } this.matterDispatcherInstalled = true; this.homebridgeIpcService.on('matterEvent', (event) => { if (!event?.correlationId) { return; } const waiter = this.matterRequests.get(event.correlationId); if (!waiter) { return; } if (event.type !== waiter.eventType) { return; } this.matterRequests.delete(event.correlationId); clearTimeout(waiter.timer); waiter.resolve(event.data); }); } async ensureMatterMonitoringStarted() { if (this.matterMonitoringActive) { return; } if (!this.matterMonitoringStartPromise) { const attempt = this.startMatterMonitoring().catch((error) => { this.logger.error('Failed to start Matter monitoring:', error); if (this.matterMonitoringStartPromise === attempt) { this.matterMonitoringStartPromise = null; } }); this.matterMonitoringStartPromise = attempt; } return this.matterMonitoringStartPromise; } async startMatterMonitoring() { const featureFlags = this.configService.getFeatureFlags(); if (!featureFlags.matterSupport || !this.configService.isMatterEnabled()) { return; } this.logger.debug('Starting Matter accessory monitoring'); const listener = (event) => { switch (event.type) { case 'accessoryUpdate': this.handleMatterStateUpdate(event.data); break; case 'accessoryAdded': case 'accessoryRemoved': this.logger.debug(`Matter accessory ${event.type}: ${event.data.uuid} - triggering reload`); for (const client of this.activeClients) { client.emit('matter-accessories-reload-required'); } break; } }; this.matterUpdateListener = listener; this.homebridgeIpcService.on('matterEvent', listener); try { if (featureFlags.matterMonitoringAck) { await this.waitForMatterEvent('monitoringStarted', (correlationId) => { this.homebridgeIpcService.sendMessage('startMatterMonitoring', { correlationId }); }); } else { this.homebridgeIpcService.sendMessage('startMatterMonitoring'); } this.matterMonitoringActive = true; this.logger.debug('Matter monitoring started successfully'); } catch (error) { this.homebridgeIpcService.removeListener('matterEvent', listener); if (this.matterUpdateListener === listener) { this.matterUpdateListener = null; } throw error; } } async loadMatterAccessories() { const featureFlags = this.configService.getFeatureFlags(); if (!featureFlags.matterSupport || !this.configService.isMatterEnabled()) { return []; } if (!this.matterMonitoringActive) { this.logger.warn('Matter monitoring not active, skipping accessory load'); return []; } try { const response = await this.waitForMatterEvent('accessoriesData', (correlationId) => { this.homebridgeIpcService.sendMessage('getMatterAccessories', { correlationId }); }); if (response.error) { throw new Error(response.error); } const accessories = response.accessories || []; this.logger.debug(`Loaded ${accessories.length} Matter accessories from IPC`); const matterServices = accessories.flatMap((accessory) => { const services = []; services.push({ ...this.transformMatterAccessory(accessory), protocol: 'matter', }); if (accessory.parts) { for (const part of accessory.parts) { services.push({ ...this.transformMatterAccessory(accessory, part), protocol: 'matter', }); } } return services; }); this.logger.debug(`Transformed ${matterServices.length} Matter services (including parts)`); const blacklist = this.configService.ui.accessoryControl?.instanceBlacklist || []; const filteredServices = blacklist.length > 0 ? matterServices.filter((s) => { if (blacklist.some(b => s.instance.username.toLowerCase() === b.toLowerCase())) { this.logger.debug(`Matter accessory '${s.displayName}' filtered by instanceBlacklist (bridge: ${s.instance.username})`); return false; } return true; }) : matterServices; this.logger.debug(`${filteredServices.length} Matter services after blacklist filtering`); this.matterAccessories = filteredServices; return filteredServices; } catch (error) { this.logger.warn('Failed to load Matter accessories:', error); return []; } } transformMatterAccessory(accessory, part) { const targetClusters = part?.clusters || accessory.clusters; const displayName = part ? `${accessory.displayName} - ${part.displayName}` : accessory.displayName; const uniqueId = this.buildMatterUniqueId(accessory.uuid, part?.id); const deviceType = part?.deviceType || accessory.deviceType; const bridgeUsername = accessory.bridge?.username || 'unknown'; if (bridgeUsername === 'unknown') { this.logger.warn(`Matter accessory '${displayName}' (${uniqueId}) has no bridge.username - layout may not persist correctly`); } return { uniqueId, uuid: accessory.uuid, serviceName: displayName, displayName, deviceType, clusters: targetClusters, partId: part?.id, protocol: 'matter', instance: { name: accessory.bridge?.name || 'Matter Bridge', username: bridgeUsername, }, accessoryInformation: { 'Name': displayName, 'Manufacturer': accessory.manufacturer || 'Unknown', 'Model': accessory.model || deviceType, 'Serial Number': accessory.serialNumber || accessory.uuid, 'Firmware Revision': accessory.firmwareRevision || '1.0.0', }, bridge: accessory.bridge, plugin: accessory.plugin, platform: accessory.platform, commissioned: accessory.commissioned, fabricCount: accessory.fabricCount, fabrics: accessory.fabrics, aid: 0, iid: 0, }; } handleMatterStateUpdate(data) { const uniqueId = this.buildMatterUniqueId(data.uuid, data.partId); const service = this.matterAccessories.find(s => s.uniqueId === uniqueId); if (!service) { return; } service.clusters[data.cluster] = { ...service.clusters[data.cluster], ...data.state, }; for (const client of this.activeClients) { client.emit('accessories-data', [service]); } } async handleMatterControl(client, control) { try { const { uuid, partId } = this.parseMatterUniqueId(control.uniqueId); const accessory = this.matterAccessories.find(acc => acc.uuid === uuid); const bridgeUsername = accessory?.bridge?.username; const response = await this.waitForMatterEvent('accessoryControlResponse', (correlationId) => { this.homebridgeIpcService.sendMessage('matterAccessoryControl', { uuid, cluster: control.cluster, attributes: control.attributes, bridgeUsername, partId, correlationId, }); }); if (!response.success) { client.emit('accessory-control-failure', response.error || 'Matter control failed'); return; } if (accessory?.clusters?.[control.cluster]) { accessory.clusters[control.cluster] = { ...accessory.clusters[control.cluster], ...control.attributes, }; for (const c of this.activeClients) { c.emit('accessories-data', [accessory]); } } } catch (error) { this.logger.error('Matter control failed:', error); client.emit('accessory-control-failure', error.message || 'Matter control failed'); } } }; AccessoriesService = __decorate([ Injectable(), __param(0, Inject(ConfigService)), __param(1, Inject(Logger)), __param(2, Inject(HomebridgeIpcService)), __param(3, Inject(JsonFileStoreService)), __metadata("design:paramtypes", [ConfigService, Logger, HomebridgeIpcService, JsonFileStoreService]) ], AccessoriesService); export { AccessoriesService }; //# sourceMappingURL=accessories.service.js.map