homebridge-config-ui-x
Version:
A web based management, configuration and control platform for Homebridge.
594 lines • 25.6 kB
JavaScript
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