UNPKG

homebridge-config-ui-x

Version:

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

607 lines • 29.7 kB
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; }; var __metadata = (this && this.__metadata) || function (k, v) { if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v); }; var __param = (this && this.__param) || function (paramIndex, decorator) { return function (target, key) { decorator(target, key, paramIndex); } }; import { Buffer } from 'node:buffer'; import { exec, spawn } from 'node:child_process'; 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 { promisify } from 'node:util'; import { Categories } from '@homebridge/hap-client/dist/hap-types.js'; 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 { AccessoriesService } from '../accessories/accessories.service.js'; import { ConfigEditorService } from '../config-editor/config-editor.service.js'; const pump = promisify(pipeline); let ServerService = 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 = this.configService.homebridgeConfig.bridge.username.split(':').join(''); this.accessoryInfoPath = join(this.configService.storagePath, 'persist', `AccessoryInfo.${this.accessoryId}.json`); } async deleteSingleDeviceAccessories(id, cachedAccessoriesDir) { const cachedAccessories = join(cachedAccessoriesDir, `cachedAccessories.${id}`); const cachedAccessoriesBackup = join(cachedAccessoriesDir, `.cachedAccessories.${id}.bak`); if (await pathExists(cachedAccessories)) { await unlink(cachedAccessories); this.logger.warn(`Bridge ${id} accessory removal: removed ${cachedAccessories}.`); } if (await pathExists(cachedAccessoriesBackup)) { await unlink(cachedAccessoriesBackup); this.logger.warn(`Bridge ${id} accessory removal: removed ${cachedAccessoriesBackup}.`); } } async deleteSingleDevicePairing(id, resetPairingInfo) { const persistPath = join(this.configService.storagePath, 'persist'); const accessoryInfo = join(persistPath, `AccessoryInfo.${id}.json`); const identifierCache = join(persistPath, `IdentifierCache.${id}.json`); try { const configFile = await this.configEditorService.getConfigFile(); const username = id.match(/.{1,2}/g).join(':').toUpperCase(); 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 .concat(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 (await pathExists(accessoryInfo)) { await unlink(accessoryInfo); this.logger.warn(`Bridge ${id} reset: removed ${accessoryInfo}.`); } if (await pathExists(identifierCache)) { await unlink(identifierCache); this.logger.warn(`Bridge ${id} reset: removed ${identifierCache}.`); } 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()) .concat(configFile.bridge.pin) .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')); this.logger.log('Homebridge bridge reset: accessories and persist directories were removed.'); } async getDevicePairings() { const persistPath = join(this.configService.storagePath, 'persist'); const devices = (await readdir(persistPath)) .filter(x => x.match(/AccessoryInfo\.([A-Fa-f0-9]+)\.json$/)); const configFile = await this.configEditorService.getConfigFile(); return Promise.all(devices.map(async (x) => { return await this.getDevicePairingById(x.split('.')[1], configFile); })); } 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 = deviceId.match(/.{1,2}/g).join(':'); const isMain = this.configService.homebridgeConfig.bridge.username.toUpperCase() === username.toUpperCase(); const pluginBlock = configFile.accessories .concat(configFile.platforms) .concat([{ _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; delete device.signSk; delete device.signPk; delete device.configHash; delete device.pairedClients; delete device.pairedClientsPermission; return device; } async deleteDevicePairing(id, resetPairingInfo) { this.logger.warn(`Shutting down Homebridge before resetting paired bridge ${id}...`); await this.homebridgeIpcService.restartAndWaitForClose(); await this.deleteSingleDevicePairing(id, resetPairingInfo); return { ok: true }; } async deleteDevicesPairing(bridges) { 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) { 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) { 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 } of bridges) { try { await this.deleteSingleDeviceAccessories(id, cachedAccessoriesDir); } 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(/^cachedAccessories\.([A-F,0-9]+)$/) || 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(/cachedAccessories\.([A-F,0-9]+)/) || 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 cached accessories...'); for (const thisCachedAccessoriesPath of cachedAccessoryPaths) { if (await pathExists(thisCachedAccessoriesPath)) { await unlink(thisCachedAccessoriesPath); this.logger.warn(`Removed ${thisCachedAccessoriesPath}.`); } } } 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 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(/-/g, ''), 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 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 || !(/^[\p{L}\p{N}][\p{L}\p{N} ']*[\p{L}\p{N}]$/u).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 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); }); }); } }; ServerService = __decorate([ Injectable(), __param(0, Inject(ConfigService)), __param(1, Inject(ConfigEditorService)), __param(2, Inject(AccessoriesService)), __param(3, Inject(HomebridgeIpcService)), __param(4, Inject(Logger)), __metadata("design:paramtypes", [ConfigService, ConfigEditorService, AccessoriesService, HomebridgeIpcService, Logger]) ], ServerService); export { ServerService }; //# sourceMappingURL=server.service.js.map