UNPKG

homebridge-config-ui-x

Version:

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

1,007 lines (1,006 loc) • 60.1 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); } }; 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); });