homebridge-config-ui-x
Version:
A web based management, configuration and control platform for Homebridge.
1,007 lines (1,006 loc) • 60.1 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); }
};
var ServerService_1;
import { Buffer } from 'node:buffer';
import { exec, spawn } from 'node:child_process';
import { createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto';
import { createWriteStream } from 'node:fs';
import { readdir, unlink } from 'node:fs/promises';
import { extname, join, resolve } from 'node:path';
import process from 'node:process';
import { pipeline } from 'node:stream';
import { createSecureContext } from 'node:tls';
import { promisify } from 'node:util';
import { Categories } from '@homebridge/hap-client/hap-types';
import { BadRequestException, Inject, Injectable, InternalServerErrorException, NotFoundException, ServiceUnavailableException, } from '@nestjs/common';
import { pathExists, readJson, remove, writeJson } from 'fs-extra/esm';
import NodeCache from 'node-cache';
import { networkInterfaces } from 'systeminformation';
import { check as tcpCheck } from 'tcp-port-used';
import { ConfigService } from '../../core/config/config.service.js';
import { HomebridgeIpcService } from '../../core/homebridge-ipc/homebridge-ipc.service.js';
import { Logger } from '../../core/logger/logger.service.js';
import { RE_ACCESSORY_INFO_FILE, RE_CACHED_ACCESSORIES, RE_CACHED_ACCESSORIES_EXACT, RE_CERTIFICATE, RE_CHAR_PAIRS, RE_COLON, RE_DEVICE_ID, RE_HEX_12, RE_HEX_ANY, RE_HYPHEN_GLOBAL, RE_PRIVATE_KEY, RE_VALID_NAME, } from '../../core/regex.constants.js';
import { SslCertGeneratorService } from '../../core/ssl/ssl-cert-generator.service.js';
import { AccessoriesService } from '../accessories/accessories.service.js';
import { ConfigEditorService } from '../config-editor/config-editor.service.js';
const pump = promisify(pipeline);
let ServerService = ServerService_1 = class ServerService {
configService;
configEditorService;
accessoriesService;
homebridgeIpcService;
logger;
serverServiceCache = new NodeCache({ stdTTL: 300 });
accessoryId;
accessoryInfoPath;
setupCode = null;
paired = false;
constructor(configService, configEditorService, accessoriesService, homebridgeIpcService, logger) {
this.configService = configService;
this.configEditorService = configEditorService;
this.accessoriesService = accessoriesService;
this.homebridgeIpcService = homebridgeIpcService;
this.logger = logger;
this.accessoryId = ServerService_1.macToHex(this.configService.homebridgeConfig.bridge.username);
this.accessoryInfoPath = join(this.configService.storagePath, 'persist', `AccessoryInfo.${this.accessoryId}.json`);
}
static macToHex(mac) {
return mac.split(':').join('').toUpperCase();
}
static hexToMac(hex) {
return hex.match(RE_CHAR_PAIRS)?.join(':').toUpperCase() || hex.toUpperCase();
}
async deleteSingleDeviceAccessories(id, cachedAccessoriesDir, protocol = 'both') {
if (protocol === 'hap' || protocol === 'both') {
const cachedAccessories = resolve(cachedAccessoriesDir, `cachedAccessories.${id}`);
const cachedAccessoriesBackup = resolve(cachedAccessoriesDir, `.cachedAccessories.${id}.bak`);
if (cachedAccessories.startsWith(cachedAccessoriesDir) && await pathExists(cachedAccessories)) {
await unlink(cachedAccessories);
this.logger.warn(`Bridge ${id} HAP accessory removal: removed ${cachedAccessories}.`);
}
if (cachedAccessoriesBackup.startsWith(cachedAccessoriesDir) && await pathExists(cachedAccessoriesBackup)) {
await unlink(cachedAccessoriesBackup);
this.logger.warn(`Bridge ${id} HAP accessory removal: removed ${cachedAccessoriesBackup}.`);
}
}
if (protocol === 'matter' || protocol === 'both') {
const matterDeviceId = ServerService_1.macToHex(id);
const matterDir = resolve(this.configService.storagePath, 'matter');
const matterAccessoriesPath = resolve(matterDir, matterDeviceId, 'accessories.json');
if (matterAccessoriesPath.startsWith(matterDir) && await pathExists(matterAccessoriesPath)) {
await unlink(matterAccessoriesPath);
this.logger.warn(`Bridge ${id} Matter accessory removal: removed ${matterAccessoriesPath}.`);
}
}
}
async deleteSingleDevicePairing(id, resetPairingInfo) {
const persistPath = resolve(this.configService.storagePath, 'persist');
const accessoryInfo = resolve(persistPath, `AccessoryInfo.${id}.json`);
const identifierCache = resolve(persistPath, `IdentifierCache.${id}.json`);
const deviceId = id.includes(':') ? ServerService_1.macToHex(id) : id.toUpperCase();
const matterDir = resolve(this.configService.storagePath, 'matter');
const matterPath = resolve(matterDir, deviceId);
try {
const configFile = await this.configEditorService.getConfigFile();
const username = id.includes(':') ? id.toUpperCase() : ServerService_1.hexToMac(id);
const uiConfig = configFile.platforms.find(x => x.platform === 'config');
let blacklistChanged = false;
let bridgesChanged = false;
if (uiConfig.accessoryControl?.instanceBlacklist?.includes(username)) {
blacklistChanged = true;
uiConfig.accessoryControl.instanceBlacklist = uiConfig.accessoryControl.instanceBlacklist
.filter((x) => x.toUpperCase() !== username);
}
let oldBridgeConfig;
if (uiConfig.bridges && Array.isArray(uiConfig.bridges)) {
const bridgeIndex = uiConfig.bridges.findIndex(x => x.username?.toUpperCase() === username);
if (bridgeIndex > -1) {
bridgesChanged = true;
oldBridgeConfig = uiConfig.bridges[bridgeIndex];
uiConfig.bridges.splice(bridgeIndex, 1);
}
}
if (resetPairingInfo) {
const pluginBlocks = [
...(configFile.accessories || []),
...(configFile.platforms || []),
{ _bridge: configFile.bridge },
]
.filter((block) => block._bridge?.username?.toUpperCase() === username.toUpperCase());
const pluginBlock = pluginBlocks.find((block) => block._bridge?.port);
const otherBlocks = pluginBlocks.filter((block) => !block._bridge?.port);
if (pluginBlock) {
pluginBlock._bridge.username = this.configEditorService.generateUsername();
pluginBlock._bridge.pin = this.configEditorService.generatePin();
otherBlocks.forEach((block) => {
block._bridge.username = pluginBlock._bridge.username;
});
if (blacklistChanged) {
uiConfig.accessoryControl.instanceBlacklist = [...uiConfig.accessoryControl.instanceBlacklist, ...pluginBlock._bridge.username];
}
if (bridgesChanged) {
uiConfig.bridges.push({
...oldBridgeConfig,
username: pluginBlock._bridge.username,
});
}
this.logger.warn(`Bridge ${id} reset: new username: ${pluginBlock._bridge.username} and new pin: ${pluginBlock._bridge.pin}.`);
}
else {
this.logger.error(`Failed to reset username and pin for child bridge ${id} as the plugin block could not be found.`);
}
}
if (blacklistChanged) {
uiConfig.accessoryControl.instanceBlacklist = uiConfig.accessoryControl.instanceBlacklist
.sort((a, b) => a.localeCompare(b));
}
await this.configEditorService.updateConfigFile(configFile);
}
catch (e) {
this.logger.error(`Failed to reset username and pin for child bridge ${id} as ${e.message}.`);
}
if (accessoryInfo.startsWith(persistPath) && await pathExists(accessoryInfo)) {
await unlink(accessoryInfo);
this.logger.warn(`Bridge ${id} reset: removed ${accessoryInfo}.`);
}
if (identifierCache.startsWith(persistPath) && await pathExists(identifierCache)) {
await unlink(identifierCache);
this.logger.warn(`Bridge ${id} reset: removed ${identifierCache}.`);
}
if (matterPath.startsWith(matterDir) && await pathExists(matterPath)) {
await remove(matterPath);
this.logger.warn(`Bridge ${id} reset: removed Matter bridge storage at ${matterPath}.`);
}
await this.deleteDeviceAccessories(id);
}
async restartServer() {
this.logger.log('Homebridge restart request received.');
if (!await this.configService.uiRestartRequired() && !await this.nodeVersionChanged()) {
this.logger.log('UI/Bridge settings have not changed - only restarting Homebridge process.');
this.homebridgeIpcService.restartHomebridge();
this.accessoriesService.resetInstancePool();
return { ok: true, command: 'SIGTERM', restartingUI: false };
}
setTimeout(() => {
if (this.configService.ui.restart) {
this.logger.log(`Executing restart command ${this.configService.ui.restart}.`);
exec(this.configService.ui.restart, (err) => {
if (err) {
this.logger.log('Restart command exited with an error, failed to restart Homebridge.');
}
});
}
else {
this.logger.log('Sending SIGTERM to process...');
process.kill(process.pid, 'SIGTERM');
}
}, 500);
return { ok: true, command: this.configService.ui.restart, restartingUI: true };
}
async resetHomebridgeAccessory() {
this.configService.hbServiceUiRestartRequired = true;
const configFile = await this.configEditorService.getConfigFile();
const oldUsername = configFile.bridge.username;
configFile.bridge.pin = this.configEditorService.generatePin();
configFile.bridge.username = this.configEditorService.generateUsername();
const uiConfig = configFile.platforms.find(x => x.platform === 'config');
if (uiConfig.accessoryControl?.instanceBlacklist?.includes(oldUsername.toUpperCase())) {
uiConfig.accessoryControl.instanceBlacklist = [...uiConfig.accessoryControl.instanceBlacklist
.filter((x) => x.toUpperCase() !== oldUsername.toUpperCase()), ...configFile.bridge.username]
.sort((a, b) => a.localeCompare(b));
}
this.logger.warn(`Homebridge bridge reset: new username ${configFile.bridge.username} and new pin ${configFile.bridge.pin}.`);
await this.configEditorService.updateConfigFile(configFile);
await remove(resolve(this.configService.storagePath, 'accessories'));
await remove(resolve(this.configService.storagePath, 'persist'));
const matterDir = join(this.configService.storagePath, 'matter');
if (await pathExists(matterDir)) {
await remove(matterDir);
this.logger.warn('Homebridge bridge reset: removed all Matter storage.');
}
this.logger.log('Homebridge bridge reset: accessories, persist, and matter directories were removed.');
}
async getDevicePairings() {
const persistPath = join(this.configService.storagePath, 'persist');
const devices = (await readdir(persistPath))
.filter(x => x.match(RE_ACCESSORY_INFO_FILE));
const configFile = await this.configEditorService.getConfigFile();
const hapDevices = await Promise.all(devices.map(async (x) => {
return await this.getDevicePairingById(x.split('.')[1], configFile);
}));
const matterExternalDevices = await this.getMatterExternalAccessories(hapDevices);
return [...hapDevices, ...matterExternalDevices].sort((a, b) => a.name.localeCompare(b.name));
}
async getMatterExternalAccessories(hapDevices) {
const matterPath = join(this.configService.storagePath, 'matter');
if (!await pathExists(matterPath)) {
return [];
}
const matterDirs = (await readdir(matterPath))
.filter(x => x.match(RE_HEX_12));
const matterExternalDevices = [];
for (const deviceId of matterDirs) {
try {
const hasHapAccessoryInfo = hapDevices.some(d => d._id === deviceId);
if (hasHapAccessoryInfo) {
continue;
}
const mainBridgeId = ServerService_1.macToHex(this.configService.homebridgeConfig.bridge.username);
if (deviceId.toUpperCase() === mainBridgeId) {
continue;
}
const accessoriesPath = join(matterPath, deviceId, 'accessories.json');
if (!await pathExists(accessoriesPath)) {
continue;
}
const accessories = await readJson(accessoriesPath);
if (!Array.isArray(accessories) || accessories.length === 0) {
continue;
}
const accessory = accessories[0];
const commissioningPath = join(matterPath, deviceId, 'commissioning.json');
let commissioned = false;
if (await pathExists(commissioningPath)) {
try {
const commissioningInfo = await readJson(commissioningPath);
commissioned = commissioningInfo.commissioned || false;
}
catch (parseError) {
this.logger.warn(`Malformed commissioning.json at ${commissioningPath}, removing corrupted file`);
try {
await remove(commissioningPath);
}
catch (removeError) {
this.logger.warn(`Failed to remove corrupted commissioning.json: ${removeError.message}`);
}
}
}
const device = {
_id: deviceId,
_username: ServerService_1.hexToMac(deviceId),
_main: false,
_category: 'other',
_matter: true,
_matterOnly: true,
_isPaired: commissioned,
_plugin: accessory.plugin,
name: accessory.displayName || 'Matter External Accessory',
displayName: accessory.displayName || 'Matter External Accessory',
manufacturer: accessory.manufacturer || 'Unknown',
model: accessory.model || 'Unknown',
serialNumber: accessory.serialNumber || deviceId,
category: 1,
};
matterExternalDevices.push(device);
}
catch (e) {
this.logger.error(`Failed to read Matter external accessory ${deviceId}: ${e.message}`);
}
}
return matterExternalDevices;
}
async getDevicePairingById(deviceId, configFile = null) {
const persistPath = join(this.configService.storagePath, 'persist');
let device;
try {
device = await readJson(join(persistPath, `AccessoryInfo.${deviceId}.json`));
}
catch (e) {
throw new NotFoundException();
}
if (!configFile) {
configFile = await this.configEditorService.getConfigFile();
}
const username = ServerService_1.hexToMac(deviceId);
const isMain = this.configService.homebridgeConfig.bridge.username.toUpperCase() === username.toUpperCase();
const pluginBlock = [...configFile.accessories, ...configFile.platforms, { _bridge: configFile.bridge }]
.find((block) => block._bridge?.username?.toUpperCase() === username.toUpperCase());
try {
device._category = Object.entries(Categories).find(([, value]) => value === device.category)[0].toLowerCase();
}
catch (e) {
device._category = 'Other';
}
device.name = pluginBlock?._bridge.name || pluginBlock?.name || device.displayName;
device._id = deviceId;
device._username = username;
device._main = isMain;
device._isPaired = device.pairedClients && Object.keys(device.pairedClients).length > 0;
device._setupCode = this.generateSetupCode(device);
device._couldBeStale = !device._main && device._category === 'bridge' && !pluginBlock;
device._matter = !!(pluginBlock?._bridge?.matter);
if (device._matter && pluginBlock && 'accessory' in pluginBlock) {
this.logger.warn(`Device ${deviceId} has Matter configuration on an accessory-based plugin. Matter is only supported for platform-based plugins.`);
}
delete device.signSk;
delete device.signPk;
delete device.configHash;
delete device.pairedClients;
delete device.pairedClientsPermission;
return device;
}
async deleteDevicePairing(id, resetPairingInfo) {
if (!RE_DEVICE_ID.test(id)) {
throw new BadRequestException('Invalid device ID.');
}
this.logger.warn(`Shutting down Homebridge before resetting paired bridge ${id}...`);
await this.homebridgeIpcService.restartAndWaitForClose();
await this.deleteSingleDevicePairing(id, resetPairingInfo);
return { ok: true };
}
async deleteDeviceMatterConfig(id) {
if (!RE_DEVICE_ID.test(id)) {
throw new BadRequestException('Invalid device ID.');
}
this.logger.warn(`Shutting down Homebridge before removing Matter config for bridge ${id}...`);
await this.homebridgeIpcService.restartAndWaitForClose();
try {
const configFile = await this.configEditorService.getConfigFile();
const username = id.includes(':') ? id.toUpperCase() : ServerService_1.hexToMac(id);
const pluginBlocks = [
...(configFile.accessories || []),
...(configFile.platforms || []),
]
.filter((block) => block._bridge?.username?.toUpperCase() === username.toUpperCase());
const pluginBlock = pluginBlocks.find((block) => block._bridge?.matter);
if (!pluginBlock) {
this.logger.error(`Failed to find Matter configuration for child bridge ${id}.`);
throw new NotFoundException(`Matter configuration not found for bridge ${id}`);
}
if ('accessory' in pluginBlock) {
this.logger.warn(`Removing Matter configuration from accessory-based plugin block for bridge ${id}. Matter is only supported for platform-based plugins.`);
}
delete pluginBlock._bridge.matter;
this.logger.warn(`Bridge ${id} Matter configuration removed from config.json.`);
await this.configEditorService.updateConfigFile(configFile);
}
catch (e) {
if (e instanceof NotFoundException) {
throw e;
}
this.logger.error(`Failed to remove Matter configuration for child bridge ${id} as ${e.message}.`);
throw new InternalServerErrorException(`Failed to remove Matter configuration: ${e.message}`);
}
const deviceId = id.includes(':') ? ServerService_1.macToHex(id) : id.toUpperCase();
const matterPath = join(this.configService.storagePath, 'matter', deviceId);
if (await pathExists(matterPath)) {
await remove(matterPath);
this.logger.warn(`Bridge ${id} Matter storage removed at ${matterPath}.`);
}
return { ok: true };
}
async deleteDevicesPairing(bridges) {
if (bridges.some(x => !RE_DEVICE_ID.test(x.id))) {
throw new BadRequestException('Invalid device ID.');
}
this.logger.warn(`Shutting down Homebridge before resetting paired bridges ${bridges.map(x => x.id).join(', ')}...`);
await this.homebridgeIpcService.restartAndWaitForClose();
for (const { id, resetPairingInfo } of bridges) {
try {
await this.deleteSingleDevicePairing(id, resetPairingInfo);
}
catch (e) {
this.logger.error(`Failed to reset paired bridge ${id} as ${e.message}.`);
}
}
return { ok: true };
}
async deleteDeviceAccessories(id) {
if (!RE_DEVICE_ID.test(id)) {
throw new BadRequestException('Invalid device ID.');
}
this.logger.warn(`Shutting down Homebridge before removing accessories for paired bridge ${id}...`);
await this.homebridgeIpcService.restartAndWaitForClose();
const cachedAccessoriesDir = join(this.configService.storagePath, 'accessories');
await this.deleteSingleDeviceAccessories(id, cachedAccessoriesDir);
}
async deleteDevicesAccessories(bridges) {
if (bridges.some(x => !RE_DEVICE_ID.test(x.id))) {
throw new BadRequestException('Invalid device ID.');
}
this.logger.warn(`Shutting down Homebridge before removing accessories for paired bridges ${bridges.map(x => x.id).join(', ')}...`);
await this.homebridgeIpcService.restartAndWaitForClose();
const cachedAccessoriesDir = join(this.configService.storagePath, 'accessories');
for (const { id, protocol } of bridges) {
try {
await this.deleteSingleDeviceAccessories(id, cachedAccessoriesDir, protocol || 'both');
}
catch (e) {
this.logger.error(`Failed to remove accessories for bridge ${id} as ${e.message}.`);
}
}
}
async getCachedAccessories() {
const cachedAccessoriesDir = join(this.configService.storagePath, 'accessories');
const cachedAccessoryFiles = (await readdir(cachedAccessoriesDir))
.filter(x => x.match(RE_CACHED_ACCESSORIES_EXACT) || x === 'cachedAccessories');
const cachedAccessories = [];
await Promise.all(cachedAccessoryFiles.map(async (x) => {
const accessories = await readJson(join(cachedAccessoriesDir, x));
for (const accessory of accessories) {
accessory.$cacheFile = x;
cachedAccessories.push(accessory);
}
}));
return cachedAccessories;
}
async deleteCachedAccessory(uuid, cacheFile) {
cacheFile = cacheFile || 'cachedAccessories';
const cachedAccessoriesPath = resolve(this.configService.storagePath, 'accessories', cacheFile);
this.logger.warn(`Shutting down Homebridge before removing cached accessory ${uuid}...`);
await this.homebridgeIpcService.restartAndWaitForClose();
const cachedAccessories = await readJson(cachedAccessoriesPath);
const accessoryIndex = cachedAccessories.findIndex(x => x.UUID === uuid);
if (accessoryIndex > -1) {
cachedAccessories.splice(accessoryIndex, 1);
await writeJson(cachedAccessoriesPath, cachedAccessories);
this.logger.warn(`Removed cached accessory with UUID ${uuid} from file ${cacheFile}.`);
}
else {
this.logger.error(`Cannot find cached accessory with UUID ${uuid} from file ${cacheFile}.`);
throw new NotFoundException();
}
return { ok: true };
}
async deleteCachedAccessories(accessories) {
this.logger.warn(`Shutting down Homebridge before removing cached accessories ${accessories.map(x => x.uuid).join(', ')}.`);
await this.homebridgeIpcService.restartAndWaitForClose();
const accessoriesByCacheFile = new Map();
for (const { cacheFile, uuid } of accessories) {
const accessoryCacheFile = cacheFile || 'cachedAccessories';
if (!accessoriesByCacheFile.has(accessoryCacheFile)) {
accessoriesByCacheFile.set(accessoryCacheFile, []);
}
accessoriesByCacheFile.get(accessoryCacheFile).push({ uuid });
}
for (const [cacheFile, accessories] of accessoriesByCacheFile.entries()) {
const cachedAccessoriesPath = resolve(this.configService.storagePath, 'accessories', cacheFile);
const cachedAccessories = await readJson(cachedAccessoriesPath);
for (const { uuid } of accessories) {
try {
const accessoryIndex = cachedAccessories.findIndex(x => x.UUID === uuid);
if (accessoryIndex > -1) {
cachedAccessories.splice(accessoryIndex, 1);
this.logger.warn(`Removed cached accessory with UUID ${uuid} from file ${cacheFile}.`);
}
else {
this.logger.error(`Cannot find cached accessory with UUID ${uuid} from file ${cacheFile}.`);
}
}
catch (e) {
this.logger.error(`Failed to remove cached accessory with UUID ${uuid} from file ${cacheFile} as ${e.message}.`);
}
}
await writeJson(cachedAccessoriesPath, cachedAccessories);
}
return { ok: true };
}
async deleteAllCachedAccessories() {
const cachedAccessoriesDir = join(this.configService.storagePath, 'accessories');
const cachedAccessoryPaths = (await readdir(cachedAccessoriesDir))
.filter(x => x.match(RE_CACHED_ACCESSORIES) || x === 'cachedAccessories' || x === '.cachedAccessories.bak')
.map(x => resolve(cachedAccessoriesDir, x));
const cachedAccessoriesPath = resolve(this.configService.storagePath, 'accessories', 'cachedAccessories');
await this.homebridgeIpcService.restartAndWaitForClose();
this.logger.warn('Shutting down Homebridge before removing cached accessories');
try {
this.logger.log('Clearing all HAP cached accessories...');
for (const thisCachedAccessoriesPath of cachedAccessoryPaths) {
if (await pathExists(thisCachedAccessoriesPath)) {
await unlink(thisCachedAccessoriesPath);
this.logger.warn(`Removed ${thisCachedAccessoriesPath}.`);
}
}
const matterDir = join(this.configService.storagePath, 'matter');
if (await pathExists(matterDir)) {
this.logger.log('Clearing all Matter cached accessories...');
const matterBridges = (await readdir(matterDir)).filter(x => x.match(RE_HEX_12));
for (const deviceId of matterBridges) {
const accessoriesPath = join(matterDir, deviceId, 'accessories.json');
if (await pathExists(accessoriesPath)) {
await unlink(accessoriesPath);
this.logger.warn(`Removed Matter cached accessories for bridge ${deviceId}.`);
}
}
}
}
catch (e) {
this.logger.error(`Failed to clear all cached accessories at ${cachedAccessoriesPath} as ${e.message}.`);
console.error(e);
throw new InternalServerErrorException('Failed to clear Homebridge accessory cache - see logs.');
}
return { ok: true };
}
async getMatterAccessories() {
const matterDir = join(this.configService.storagePath, 'matter');
if (!await pathExists(matterDir)) {
return [];
}
const matterBridges = (await readdir(matterDir))
.filter(x => x.match(RE_HEX_ANY));
const matterAccessories = [];
await Promise.all(matterBridges.map(async (deviceId) => {
try {
const accessoriesPath = join(matterDir, deviceId, 'accessories.json');
if (await pathExists(accessoriesPath)) {
const accessories = await readJson(accessoriesPath);
if (Array.isArray(accessories)) {
for (const accessory of accessories) {
accessory.$deviceId = deviceId;
accessory.$protocol = 'matter';
matterAccessories.push(accessory);
}
}
}
}
catch (e) {
this.logger.error(`Failed to read Matter accessories for bridge ${deviceId}: ${e.message}`);
}
}));
return matterAccessories;
}
async deleteMatterAccessory(deviceId, uuid) {
const matterAccessoriesPath = join(this.configService.storagePath, 'matter', deviceId, 'accessories.json');
if (!await pathExists(matterAccessoriesPath)) {
this.logger.error(`Matter accessories file not found for bridge ${deviceId}`);
throw new NotFoundException();
}
this.logger.warn(`Shutting down Homebridge before removing Matter accessory ${uuid} from bridge ${deviceId}...`);
await this.homebridgeIpcService.restartAndWaitForClose();
const matterAccessories = await readJson(matterAccessoriesPath);
const accessoryIndex = matterAccessories.findIndex(x => x.uuid === uuid);
if (accessoryIndex > -1) {
matterAccessories.splice(accessoryIndex, 1);
await writeJson(matterAccessoriesPath, matterAccessories, { spaces: 2 });
this.logger.warn(`Removed Matter accessory with UUID ${uuid} from bridge ${deviceId}.`);
}
else {
this.logger.error(`Cannot find Matter accessory with UUID ${uuid} in bridge ${deviceId}.`);
throw new NotFoundException();
}
return { ok: true };
}
async deleteMatterAccessories(accessories) {
this.logger.warn(`Shutting down Homebridge before removing Matter accessories ${accessories.map(x => x.uuid).join(', ')}.`);
await this.homebridgeIpcService.restartAndWaitForClose();
const accessoriesByBridge = new Map();
for (const { deviceId, uuid } of accessories) {
if (!accessoriesByBridge.has(deviceId)) {
accessoriesByBridge.set(deviceId, []);
}
accessoriesByBridge.get(deviceId).push({ uuid });
}
for (const [deviceId, bridgeAccessories] of accessoriesByBridge.entries()) {
const matterAccessoriesPath = join(this.configService.storagePath, 'matter', deviceId, 'accessories.json');
try {
if (!await pathExists(matterAccessoriesPath)) {
this.logger.error(`Matter accessories file not found for bridge ${deviceId}`);
continue;
}
const matterAccessories = await readJson(matterAccessoriesPath);
for (const { uuid } of bridgeAccessories) {
try {
const accessoryIndex = matterAccessories.findIndex(x => x.uuid === uuid);
if (accessoryIndex > -1) {
matterAccessories.splice(accessoryIndex, 1);
this.logger.warn(`Removed Matter accessory with UUID ${uuid} from bridge ${deviceId}.`);
}
else {
this.logger.error(`Cannot find Matter accessory with UUID ${uuid} in bridge ${deviceId}.`);
}
}
catch (e) {
this.logger.error(`Failed to remove Matter accessory with UUID ${uuid} from bridge ${deviceId} as ${e.message}.`);
}
}
await writeJson(matterAccessoriesPath, matterAccessories, { spaces: 2 });
}
catch (e) {
this.logger.error(`Failed to process Matter accessories for bridge ${deviceId} as ${e.message}.`);
}
}
return { ok: true };
}
async getSetupCode() {
if (this.setupCode) {
return this.setupCode;
}
else {
if (!await pathExists(this.accessoryInfoPath)) {
return null;
}
const accessoryInfo = await readJson(this.accessoryInfoPath);
this.setupCode = this.generateSetupCode(accessoryInfo);
return this.setupCode;
}
}
generateSetupCode(accessoryInfo) {
const buffer = Buffer.allocUnsafe(8);
let valueLow = Number.parseInt(accessoryInfo.pincode.replace(RE_HYPHEN_GLOBAL, ''), 10);
const valueHigh = accessoryInfo.category >> 1;
valueLow |= 1 << 28;
buffer.writeUInt32BE(valueLow, 4);
if (accessoryInfo.category & 1) {
buffer[4] = buffer[4] | 1 << 7;
}
buffer.writeUInt32BE(valueHigh, 0);
let encodedPayload = (buffer.readUInt32BE(4) + (buffer.readUInt32BE(0) * 2 ** 32)).toString(36).toUpperCase();
if (encodedPayload.length !== 9) {
for (let i = 0; i <= 9 - encodedPayload.length; i += 1) {
encodedPayload = `0${encodedPayload}`;
}
}
return `X-HM://${encodedPayload}${accessoryInfo.setupID}`;
}
async getBridgePairingInformation() {
if (!await pathExists(this.accessoryInfoPath)) {
return new ServiceUnavailableException('Pairing Information Not Available Yet');
}
const accessoryInfo = await readJson(this.accessoryInfoPath);
return {
displayName: accessoryInfo.displayName,
pincode: accessoryInfo.pincode,
setupCode: await this.getSetupCode(),
isPaired: accessoryInfo.pairedClients && Object.keys(accessoryInfo.pairedClients).length > 0,
};
}
async getSystemNetworkInterfaces() {
const fromCache = this.serverServiceCache.get('network-interfaces');
const interfaces = fromCache || (await networkInterfaces()).filter((adapter) => {
return !adapter.internal
&& (adapter.ip4 || (adapter.ip6));
});
if (!fromCache) {
this.serverServiceCache.set('network-interfaces', interfaces);
}
return interfaces;
}
async getHomebridgeNetworkInterfaces() {
const config = await this.configEditorService.getConfigFile();
if (!config.bridge?.bind) {
return [];
}
if (Array.isArray(config.bridge?.bind)) {
return config.bridge.bind;
}
if (typeof config.bridge?.bind === 'string') {
return [config.bridge.bind];
}
return [];
}
async getHomebridgeMdnsSetting() {
const config = await this.configEditorService.getConfigFile();
if (!config.bridge.advertiser) {
config.bridge.advertiser = 'bonjour-hap';
}
return {
advertiser: config.bridge.advertiser,
};
}
async setHomebridgeMdnsSetting(setting) {
const config = await this.configEditorService.getConfigFile();
config.bridge.advertiser = setting.advertiser;
await this.configEditorService.updateConfigFile(config);
}
async setHomebridgeNetworkInterfaces(adapters) {
const config = await this.configEditorService.getConfigFile();
if (!config.bridge) {
config.bridge = {};
}
if (!adapters.length) {
delete config.bridge.bind;
}
else {
config.bridge.bind = adapters;
}
await this.configEditorService.updateConfigFile(config);
}
async lookupUnusedPort() {
const min = this.configService.homebridgeConfig.ports?.start ?? 30000;
const max = this.configService.homebridgeConfig.ports?.end ?? 60000;
const randomPort = () => Math.floor(Math.random() * (max - min + 1) + min);
let port = randomPort();
while (await tcpCheck(port)) {
port = randomPort();
}
return { port };
}
async lookupUnusedMatterPort() {
const config = await this.configEditorService.getConfigFile();
const min = config.matterPorts?.start ?? 5530;
const max = config.matterPorts?.end ?? 5541;
const usedMatterPorts = new Set();
if (config.bridge?.matter?.port) {
usedMatterPorts.add(config.bridge.matter.port);
}
for (const block of [...(config.accessories || []), ...(config.platforms || [])]) {
if (block._bridge?.matter?.port) {
if ('accessory' in block) {
this.logger.warn(`Found Matter configuration on accessory-based plugin block, skipping port ${block._bridge.matter.port}`);
continue;
}
usedMatterPorts.add(block._bridge.matter.port);
}
}
for (let port = min; port <= max; port += 1) {
if (!usedMatterPorts.has(port) && !await tcpCheck(port)) {
return { port };
}
}
throw new InternalServerErrorException(`No available ports in the Matter port range (${min}-${max})`);
}
async getHomebridgePort() {
const config = await this.configEditorService.getConfigFile();
return { port: config.bridge.port };
}
async getUsablePorts() {
const config = await this.configEditorService.getConfigFile();
let start;
let end;
if (config.ports && typeof config.ports === 'object') {
if (config.ports.start) {
start = config.ports.start;
}
if (config.ports.end) {
end = config.ports.end;
}
}
return { start, end };
}
async setHomebridgeName(name) {
if (!name || !RE_VALID_NAME.test(name)) {
throw new BadRequestException('Invalid name');
}
const config = await this.configEditorService.getConfigFile();
config.bridge.name = name;
await this.configEditorService.updateConfigFile(config);
}
async setHomebridgePort(port) {
if (!port || typeof port !== 'number' || !Number.isInteger(port) || port < 1025 || port > 65533) {
throw new BadRequestException('Invalid port number');
}
const config = await this.configEditorService.getConfigFile();
config.bridge.port = port;
await this.configEditorService.updateConfigFile(config);
}
async setUsablePorts(value) {
let config = await this.configEditorService.getConfigFile();
if (value.start === null) {
delete value.start;
}
if (value.end === null) {
delete value.end;
}
if ('start' in value && (typeof value.start !== 'number' || value.start < 1025 || value.start > 65533)) {
throw new BadRequestException('Port start must be a number between 1025 and 65533.');
}
if ('end' in value && (typeof value.end !== 'number' || value.end < 1025 || value.end > 65533)) {
throw new BadRequestException('Port end must be a number between 1025 and 65533.');
}
if ('start' in value && 'end' in value && value.start >= value.end) {
throw new BadRequestException('Ports start must be less than end.');
}
if ('start' in value && !('end' in value) && config.ports?.end && value.start >= config.ports.end) {
throw new BadRequestException('Ports start must be less than end.');
}
if ('end' in value && !('start' in value) && config.ports?.start && config.ports.start >= value.end) {
throw new BadRequestException('Ports start must be less than end.');
}
if (!value.start && !value.end) {
delete config.ports;
}
else {
config.ports = {};
if (value.start) {
config.ports.start = value.start;
}
if (value.end) {
config.ports.end = value.end;
}
}
const { bridge, ports, ...rest } = config;
config = ports ? { bridge, ports, ...rest } : { bridge, ...rest };
await this.configEditorService.updateConfigFile(config);
}
async getNetworkOverview() {
const config = await this.configEditorService.getConfigFile();
const entries = [];
const portMap = new Map();
const matterDir = join(this.configService.storagePath, 'matter');
const trackPort = (port, label, protocol) => {
if (!port) {
return;
}
if (!portMap.has(port)) {
portMap.set(port, []);
}
portMap.get(port).push(`${label} (${protocol})`);
};
const readMatterDiagnostics = async (username) => {
const deviceId = username.replace(RE_COLON, '').toUpperCase();
let commissioned = false;
let deviceCount = 0;
if (await pathExists(matterDir)) {
const commissioningPath = join(matterDir, deviceId, 'commissioning.json');
if (await pathExists(commissioningPath)) {
try {
const info = await readJson(commissioningPath);
commissioned = info.commissioned || false;
}
catch {
}
}
const accessoriesPath = join(matterDir, deviceId, 'accessories.json');
if (await pathExists(accessoriesPath)) {
try {
const accessories = await readJson(accessoriesPath);
deviceCount = Array.isArray(accessories) ? accessories.length : 0;
}
catch {
}
}
}
return { commissioned, deviceCount };
};
const mainBridgeName = config.bridge.name || 'Homebridge';
const mainEntry = {
service: 'Homebridge',
port: config.bridge.port,
protocol: 'HAP',
bridge: mainBridgeName,
status: 'ok',
};
trackPort(config.bridge.port, mainBridgeName, 'HAP');
if (config.bridge.matter?.port) {
mainEntry.matterPort = config.bridge.matter.port;
trackPort(config.bridge.matter.port, mainBridgeName, 'Matter');
const diag = await readMatterDiagnostics(config.bridge.username);
mainEntry.commissioned = diag.commissioned;
mainEntry.deviceCount = diag.deviceCount;
}
entries.push(mainEntry);
for (const block of [...(config.accessories || []), ...(config.platforms || [])]) {
if (block._bridge) {
const bridgeName = block._bridge.name || block.name || ('platform' in block ? block.platform : block.accessory);
const entry = {
service: bridgeName,
port: block._bridge.port || 0,
protocol: 'HAP',
bridge: bridgeName,
status: 'ok',
};
if (block._bridge.port) {
trackPort(block._bridge.port, bridgeName, 'HAP');
}
if (block._bridge.matter?.port && !('accessory' in block)) {
entry.matterPort = block._bridge.matter.port;
trackPort(block._bridge.matter.port, bridgeName, 'Matter');
const diag = await readMatterDiagnostics(block._bridge.username);
entry.commissioned = diag.commissioned;
entry.deviceCount = diag.deviceCount;
}
entries.push(entry);
}
}
const uiConfig = config.platforms?.find(x => x.platform === 'config');
const uiPort = uiConfig?.port || this.configService.ui.port;
entries.push({
service: 'Config UI',
port: uiPort,
protocol: 'UI',
bridge: 'UI',
status: 'ok',
});
trackPort(uiPort, 'UI', 'UI');
const conflicts = [];
for (const [port, services] of portMap) {
if (services.length > 1) {
conflicts.push(`Port ${port} is used by: ${services.join(', ')}`);
}
}
for (const entry of entries) {
if (portMap.get(entry.port)?.length > 1) {
entry.status = 'conflict';
}
if (entry.matterPort && portMap.get(entry.matterPort)?.length > 1) {
entry.status = 'conflict';
}
}
return { entries, conflicts };
}
async uploadWallpaper(data) {
const configFile = await this.configEditorService.getConfigFile();
const uiConfigBlock = configFile.platforms.find(x => x.platform === 'config');
if (uiConfigBlock) {
if (uiConfigBlock.wallpaper) {
const oldPath = join(this.configService.storagePath, uiConfigBlock.wallpaper);
if (await pathExists(oldPath)) {
try {
await unlink(oldPath);
this.logger.log(`Old wallpaper file ${oldPath} deleted successfully.`);
}
catch (e) {
this.logger.error(`Failed to delete old wallpaper ${oldPath} as ${e.message}.`);
}
}
}
const fileExtension = extname(data.filename);
const newPath = join(this.configService.storagePath, `ui-wallpaper${fileExtension}`);
await pump(data.file, createWriteStream(newPath));
uiConfigBlock.wallpaper = `ui-wallpaper${fileExtension}`;
await this.configEditorService.updateConfigFile(configFile);
this.logger.log('Wallpaper uploaded and set in the config file.');
}
}
async deleteWallpaper() {
const configFile = await this.configEditorService.getConfigFile();
const uiConfigBlock = configFile.platforms.find(x => x.platform === 'config');
const fullPath = join(this.configService.storagePath, uiConfigBlock.wallpaper);
if (uiConfigBlock && uiConfigBlock.wallpaper) {
if (await pathExists(fullPath)) {
try {
await unlink(fullPath);
this.logger.log(`Wallpaper file ${uiConfigBlock.wallpaper} deleted successfully.`);
}
catch (e) {
this.logger.error(`Failed to delete wallpaper file (${uiConfigBlock.wallpaper}) as ${e.message}.`);
}
}
delete uiConfigBlock.wallpaper;
await this.configEditorService.updateConfigFile(configFile);
this.configService.removeWallpaperCache();
this.logger.log('Wallpaper reference removed from the config file.');
}
}
async nodeVersionChanged() {
return new Promise((res) => {
let result = false;
const child = spawn(process.execPath, ['-v']);
child.stdout.once('data', (data) => {
result = data.toString().trim() !== process.version;
});
child.on('error', () => {
result = true;
});
child.on('close', () => {
return res(result);
});
});
}
async uploadSslKeyCert(req) {
const parts = req.parts ? req.parts() : null;
const files = [];
if (parts) {
for await (const part of parts) {
if (part.file) {
files.push(part);
}
}
}
else {
const single = await req.file();
if (single?.file) {
files.push(single);
}
}
if (!files.length) {
throw new BadRequestException('No files uploaded. Please upload both the private key and certificate files.');
}
const readStreamToBuffer = async (stream) => {
const chunks = [];
await new Promise((resolvePromise, rejectPromise) => {
stream.on('data', (d) => chunks.push(Buffer.isBuffer(d) ? d : Buffer.from(d)));
stream.on('end', () => resolvePromise());
stream.on('error', rejectPromise);
});