UNPKG

homebridge-config-ui-x

Version:

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

645 lines • 27.3 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 { exec, execSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { cpus, loadavg, platform, userInfo } from 'node:os'; import { dirname, resolve } from 'node:path'; import process from 'node:process'; import { promisify } from 'node:util'; import { HttpService } from '@nestjs/axios'; import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { readJson, writeJsonSync } from 'fs-extra/esm'; import NodeCache from 'node-cache'; import { firstValueFrom, Subject } from 'rxjs'; import { gt } from 'semver'; import { cpuTemperature, currentLoad, mem, networkInterfaceDefault, networkInterfaces, networkStats, osInfo, time, } from 'systeminformation'; 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 { isNodeV24SupportedArchitecture } from '../../core/node-version.constants.js'; import { RE_BETA_DATE, RE_STABLE_DATE, RE_TEST_DATE, RE_TRAILING_DATE } from '../../core/regex.constants.js'; import { PluginsService } from '../plugins/plugins.service.js'; import { ServerService } from '../server/server.service.js'; import { HomebridgeStatus, } from './status.interfaces.js'; const execAsync = promisify(exec); let StatusService = class StatusService { httpService; logger; configService; pluginsService; serverService; homebridgeIpcService; statusCache = new NodeCache({ stdTTL: 3600 }); dashboardLayout; homebridgeStatus = HomebridgeStatus.DOWN; homebridgeStatusChange = new Subject(); matterInfo = { enabled: false, }; cpuLoadHistory = []; memoryUsageHistory = []; memoryInfo; rpiGetThrottledMapping = { 0: 'Under-voltage detected', 1: 'Arm frequency capped', 2: 'Currently throttled', 3: 'Soft temperature limit active', 16: 'Under-voltage has occurred', 17: 'Arm frequency capping has occurred', 18: 'Throttled has occurred', 19: 'Soft temperature limit has occurred', }; constructor(httpService, logger, configService, pluginsService, serverService, homebridgeIpcService) { this.httpService = httpService; this.logger = logger; this.configService = configService; this.pluginsService = pluginsService; this.serverService = serverService; this.homebridgeIpcService = homebridgeIpcService; if (platform() === 'freebsd') { this.getCpuLoadPoint = this.getCpuLoadPointAlt; this.getCpuTemp = this.getCpuTempAlt; } if (this.configService.ui.disableServerMetricsMonitoring !== true) { setInterval(async () => { this.getCpuLoadPoint(); this.getMemoryUsagePoint(); }, 10000); } else { this.logger.warn('Server metrics monitoring disabled.'); } this.homebridgeIpcService.on('serverStatusUpdate', (data) => { this.homebridgeStatus = data.status; if (data.status === HomebridgeStatus.DOWN) { this.matterInfo = { enabled: false }; } if (data?.setupUri) { this.serverService.setupCode = data.setupUri; this.serverService.paired = data.paired; } if (data?.matter) { this.matterInfo = data.matter; } this.homebridgeStatusChange.next(this.homebridgeStatus); }); } async getCpuLoadPoint() { const load = (await currentLoad()).currentLoad; this.cpuLoadHistory = this.cpuLoadHistory.slice(-60); this.cpuLoadHistory.push(load); } async getMemoryUsagePoint() { const memory = await mem(); this.memoryInfo = memory; const memoryFreePercent = ((memory.total - memory.available) / memory.total) * 100; this.memoryUsageHistory = this.memoryUsageHistory.slice(-60); this.memoryUsageHistory.push(memoryFreePercent); } async getCpuLoadPointAlt() { const load = (loadavg()[0] * 100 / cpus().length); this.cpuLoadHistory = this.cpuLoadHistory.slice(-60); this.cpuLoadHistory.push(load); } async getCpuTemp() { const cpuTempData = await cpuTemperature(); if (cpuTempData.main === -1 && this.configService.ui.temp) { return this.getCpuTempLegacy(); } return cpuTempData; } async getCpuTempLegacy() { try { const tempData = await readFile(this.configService.ui.temp, 'utf-8'); const cpuTemp = Number.parseInt(tempData, 10) / 1000; return { main: cpuTemp, cores: [], max: cpuTemp, }; } catch (e) { this.logger.error(`Failed to read temp from ${this.configService.ui.temp} as ${e.message}.`); return this.getCpuTempAlt(); } } async getCpuTempAlt() { return { main: -1, cores: [], max: -1, }; } async getCurrentNetworkUsage(netInterfaces) { if (!netInterfaces || !netInterfaces.length) { netInterfaces = [await networkInterfaceDefault()]; } const net = await networkStats(netInterfaces.join(',')); const txRxSec = (net[0].tx_sec + net[0].rx_sec) / 1024 / 1024; return { net: net[0], point: txRxSec }; } async getDashboardLayout() { if (!this.dashboardLayout) { try { const layout = await readJson(resolve(this.configService.storagePath, '.uix-dashboard.json')); this.dashboardLayout = layout; return layout; } catch (e) { return []; } } else { return this.dashboardLayout; } } async getDashboardInit() { const layout = await this.getDashboardLayout(); if (!this.configService.runningOnRaspberryPi) { return { layout }; } try { const rpiThrottled = await this.getRaspberryPiThrottledStatus(); return { layout, rpiThrottled }; } catch (e) { this.logger.debug(`Failed to attach Raspberry Pi throttled status to dashboard init: ${e.message}.`); return { layout }; } } async setDashboardLayout(layout) { writeJsonSync(resolve(this.configService.storagePath, '.uix-dashboard.json'), layout); this.dashboardLayout = layout; return { status: 'ok' }; } async getServerCpuInfo() { if (!this.memoryUsageHistory.length) { await this.getCpuLoadPoint(); } return { cpuTemperature: await this.getCpuTemp(), currentLoad: this.cpuLoadHistory.slice(-1)[0], cpuLoadHistory: this.cpuLoadHistory, }; } async getServerMemoryInfo() { if (!this.memoryUsageHistory.length) { await this.getMemoryUsagePoint(); } return { mem: this.memoryInfo, memoryUsageHistory: this.memoryUsageHistory, }; } async getServerUptimeInfo() { return { time: time(), processUptime: process.uptime(), }; } async getHomebridgePairingPin() { return { pin: this.configService.homebridgeConfig.bridge.pin, setupUri: await this.serverService.getSetupCode(), paired: this.serverService.paired, hap: this.getHapInfo(), matter: this.matterInfo, }; } getHapInfo() { const hap = this.configService.homebridgeConfig.bridge.hap; let enabled = true; let externalsOnly = false; if (hap === false) { enabled = false; } else if (typeof hap === 'object' && hap !== null) { enabled = hap.enabled !== false; externalsOnly = hap.externalsOnly === true; } return { enabled, externalsOnly }; } async getHomebridgeStatus() { return { status: this.homebridgeStatus, consolePort: this.configService.ui.port, name: this.configService.homebridgeConfig.bridge.name, port: this.configService.homebridgeConfig.bridge.port, pin: this.configService.homebridgeConfig.bridge.pin, setupUri: this.serverService.setupCode, packageVersion: this.configService.package.version, paired: this.serverService.paired, hap: this.getHapInfo(), matter: this.matterInfo, }; } async watchStats(client) { let homebridgeStatusInterval; let disposed = false; client.emit('homebridge-status', await this.getHomebridgeStats()); const homebridgeStatusChangeSub = this.homebridgeStatusChange.subscribe(async () => { const stats = await this.getHomebridgeStats(); if (disposed) { return; } client.emit('homebridge-status', stats); }); const onEnd = () => { disposed = true; client.removeAllListeners('end'); client.removeAllListeners('disconnect'); if (homebridgeStatusInterval) { clearInterval(homebridgeStatusInterval); } homebridgeStatusChangeSub.unsubscribe(); }; client.on('end', onEnd.bind(this)); client.on('disconnect', onEnd.bind(this)); } async getHomebridgeStats() { return { consolePort: this.configService.ui.port, port: this.configService.homebridgeConfig.bridge.port, pin: this.configService.homebridgeConfig.bridge.pin, setupUri: await this.serverService.getSetupCode(), paired: this.serverService.paired, packageVersion: this.configService.package.version, status: await this.checkHomebridgeStatus(), hap: this.getHapInfo(), matter: this.matterInfo, }; } async checkHomebridgeStatus() { return this.homebridgeStatus; } async getDefaultInterface() { const cachedResult = this.statusCache.get('defaultInterface'); if (cachedResult) { return cachedResult; } const defaultInterfaceName = await networkInterfaceDefault(); const defaultInterface = defaultInterfaceName ? (await networkInterfaces()).find(x => x.iface === defaultInterfaceName) : undefined; if (defaultInterface) { this.statusCache.set('defaultInterface', defaultInterface); } return defaultInterface; } async getOsInfo() { const cachedResult = this.statusCache.get('osInfo'); if (cachedResult) { return cachedResult; } const osInformation = await osInfo(); this.statusCache.set('osInfo', osInformation, 86400); return osInformation; } getGlibcVersion() { if (platform() !== 'linux') { return ''; } const cachedResult = this.statusCache.get('glibcVersion'); if (cachedResult) { return cachedResult; } try { const glibcVersion = execSync('getconf GNU_LIBC_VERSION 2>/dev/null').toString().split('glibc')[1].trim(); this.statusCache.set('glibcVersion', glibcVersion, 86400); return glibcVersion; } catch (e) { this.logger.debug(`Could not check glibc version as ${e.message}.`); return ''; } } async getHomebridgeServerInfo() { return { serviceUser: userInfo().username, homebridgeConfigJsonPath: this.configService.configPath, homebridgeStoragePath: this.configService.storagePath, homebridgeInsecureMode: this.configService.homebridgeInsecureMode, homebridgeCustomPluginPath: this.configService.customPluginPath, homebridgePluginPath: resolve(process.env.UIX_BASE_PATH, '..'), homebridgeRunningInDocker: this.configService.runningInDocker, homebridgeRunningInSynologyPackage: this.configService.runningInSynologyPackage, homebridgeRunningInPackageMode: this.configService.runningInPackageMode, nodeVersion: process.version, os: await this.getOsInfo(), time: time(), network: await this.getDefaultInterface() || {}, }; } async getHomebridgeVersion() { return this.pluginsService.getHomebridgePackage(); } async getVersionOverview() { const [serverInfoResult, nodeResult, homebridgeResult, homebridgeUiResult, outOfDatePluginsResult, installedPluginsResult,] = await Promise.allSettled([ this.getHomebridgeServerInfo(), this.getNodeVersionInfo(), this.pluginsService.getHomebridgePackage(), this.pluginsService.getHomebridgeUiPackage(), this.pluginsService.getOutOfDatePlugins(), this.pluginsService.getInstalledPlugins(), ]); const settled = (result, label, fallback) => { if (result.status === 'fulfilled') { return result.value; } this.logger.error(`Failed to load ${label} for version overview as ${result.reason?.message ?? result.reason}.`); return fallback; }; const serverInfo = settled(serverInfoResult, 'server info', null); const node = settled(nodeResult, 'node version info', null); const homebridge = settled(homebridgeResult, 'homebridge package', null); const homebridgeUi = settled(homebridgeUiResult, 'homebridge-ui package', null); const outOfDatePlugins = settled(outOfDatePluginsResult, 'out-of-date plugins', []); const installedPlugins = settled(installedPluginsResult, 'installed plugins', []); const hbV2Ready = installedPlugins .filter(p => p.name !== 'homebridge-config-ui-x') .every((p) => { const hbEngines = p.engines?.homebridge?.split('||').map(s => s.trim()) || []; return hbEngines.some(v => v.startsWith('^2') || v.startsWith('>=2')); }); let docker = null; if (serverInfo?.homebridgeRunningInDocker) { try { docker = await this.getDockerDetails(); } catch (e) { this.logger.error(`Failed to load docker details for version overview as ${e.message}.`); } } return { serverInfo, node, homebridge, homebridgeUi, outOfDatePlugins, docker, hbV2Ready, }; } clearNodeJsVersionCache() { this.statusCache.del('nodeVersion:all'); this.statusCache.del('nodeVersion:none'); this.statusCache.del('nodeVersion:major'); } async getNodeVersionInfo() { const nodeUpdatePolicy = this.configService.getNodeUpdatePolicy(); const cacheKey = `nodeVersion:${nodeUpdatePolicy}`; const cachedResult = this.statusCache.get(cacheKey); if (cachedResult) { return cachedResult; } const isNodeJs24Supported = isNodeV24SupportedArchitecture(); try { const versionList = (await firstValueFrom(this.httpService.get('https://nodejs.org/dist/index.json'))).data; const latest22 = versionList.filter((x) => x.version.startsWith('v22'))[0]; const latest24 = versionList.filter((x) => x.version.startsWith('v24'))[0]; let updateAvailable = false; let latestVersion = process.version; let showNodeUnsupportedWarning = false; switch (process.version.split('.')[0]) { case 'v20': { if (isNodeJs24Supported) { updateAvailable = true; latestVersion = latest24.version; } else { updateAvailable = true; latestVersion = latest22.version; } break; } case 'v22': { if (gt(latest22.version, process.version)) { updateAvailable = true; latestVersion = latest22.version; } else if (isNodeJs24Supported) { updateAvailable = true; latestVersion = latest24.version; } break; } case 'v24': { if (gt(latest24.version, process.version)) { updateAvailable = true; latestVersion = latest24.version; } break; } default: { showNodeUnsupportedWarning = true; } } let npmVersion = null; try { const { stdout } = await execAsync('npm --version'); npmVersion = `v${stdout.trim()}`; } catch (e) { this.logger.debug(`Could not check npm version as ${e.message}.`); } const nodeUpdatePolicy = this.configService.getNodeUpdatePolicy(); let finalUpdateAvailable = updateAvailable; let finalLatestVersion = latestVersion; if (nodeUpdatePolicy === 'none') { finalUpdateAvailable = false; } else if (nodeUpdatePolicy === 'major') { const currentMajor = Number.parseInt(process.version.split('.')[0].replace('v', ''), 10); const latestMajor = Number.parseInt(latestVersion.split('.')[0].replace('v', ''), 10); if (latestMajor > currentMajor) { const latestInCurrentMajor = versionList .filter((x) => x.version.startsWith(`v${currentMajor}`)) .sort((a, b) => { return b.version.localeCompare(a.version, undefined, { numeric: true, sensitivity: 'base' }); })[0]; if (latestInCurrentMajor && gt(latestInCurrentMajor.version, process.version)) { finalLatestVersion = latestInCurrentMajor.version; finalUpdateAvailable = true; } else { finalUpdateAvailable = false; } } } const versionInformation = { currentVersion: process.version, latestVersion: finalLatestVersion, updateAvailable: finalUpdateAvailable, showNodeUnsupportedWarning, installPath: dirname(process.execPath), npmVersion, architecture: process.arch, supportsNodeJs24: isNodeJs24Supported, }; this.statusCache.set(cacheKey, versionInformation, 86400); return versionInformation; } catch (e) { this.logger.log(`Failed to check for Node.js version updates (check your internet connection) as ${e.message}.`); const versionInformation = { currentVersion: process.version, latestVersion: process.version, updateAvailable: false, showNodeUnsupportedWarning: false, architecture: process.arch, supportsNodeJs24: isNodeJs24Supported, }; this.statusCache.set(cacheKey, versionInformation, 3600); return versionInformation; } } async getRaspberryPiThrottledStatus() { if (!this.configService.runningOnRaspberryPi) { throw new BadRequestException('This command is only available on Raspberry Pi'); } const output = {}; for (const bit of Object.keys(this.rpiGetThrottledMapping)) { output[this.rpiGetThrottledMapping[bit]] = false; } try { const { stdout } = await execAsync('vcgencmd get_throttled'); const throttledHex = Number.parseInt(stdout.trim().replace('throttled=', '')); if (!Number.isNaN(throttledHex)) { for (const bit of Object.keys(this.rpiGetThrottledMapping)) { output[this.rpiGetThrottledMapping[bit]] = !!((throttledHex >> Number.parseInt(bit, 10)) & 1); } } } catch (e) { this.logger.debug(`Could not check vcgencmd get_throttled as ${e.message}.`); } return output; } async getDockerDetails() { const currentVersion = process.env.DOCKER_HOMEBRIDGE_VERSION; let latestVersion = null; let latestReleaseBody = ''; let updateAvailable = false; try { const { releases, rawReleases } = await this.getRecentReleases(); if (currentVersion) { const lowerCurrentVersion = currentVersion.toLowerCase(); let targetReleases = []; if (lowerCurrentVersion.startsWith('beta-')) { targetReleases = releases .filter(release => release.testTag === 'beta' && RE_BETA_DATE.test(release.version)) .sort((a, b) => b.version.localeCompare(a.version)); latestVersion = targetReleases[0]?.version || null; } else if (lowerCurrentVersion.startsWith('test-')) { targetReleases = releases .filter(release => release.testTag === 'test' && RE_TEST_DATE.test(release.version)) .sort((a, b) => b.version.localeCompare(a.version)); latestVersion = targetReleases[0]?.version || null; } else { const stableRelease = releases.find(release => release.isLatestStable); latestVersion = stableRelease?.version || null; } if (currentVersion && latestVersion) { if (RE_TRAILING_DATE.test(currentVersion) && RE_TRAILING_DATE.test(latestVersion)) { const currentDate = new Date(currentVersion.match(RE_TRAILING_DATE)[0]); const latestDate = new Date(latestVersion.match(RE_TRAILING_DATE)[0]); updateAvailable = latestDate > currentDate; } else { updateAvailable = currentVersion !== latestVersion; } } } else { const stableRelease = releases.find(release => release.isLatestStable); latestVersion = stableRelease?.version || null; } if (latestVersion) { const rawRelease = rawReleases.find(r => r.tag_name === latestVersion); latestReleaseBody = rawRelease?.body || ''; } } catch (error) { console.error('Failed to fetch Docker details:', error instanceof Error ? error.message : error); } return { currentVersion, latestVersion, latestReleaseBody, updateAvailable, }; } DOCKER_GITHUB_API_URL = 'https://api.github.com/repos/homebridge/docker-homebridge/releases'; async getRecentReleases() { try { const response = await fetch(`${this.DOCKER_GITHUB_API_URL}?per_page=100`, { headers: { Accept: 'application/vnd.github.v3+json', }, }); if (!response.ok) { console.error(`GitHub API error: ${response.status} ${response.statusText}`); return { releases: [], rawReleases: [] }; } const data = await response.json(); if (!Array.isArray(data)) { console.error('Invalid response from GitHub API: Expected an array'); return { releases: [], rawReleases: [] }; } const stableReleases = data .filter(release => RE_STABLE_DATE.test(release.tag_name)) .sort((a, b) => b.tag_name.localeCompare(a.tag_name)); const latestStableTag = stableReleases[0]?.tag_name || null; const releases = data.map((release) => { const tagName = release.tag_name.toLowerCase(); let testTag = null; if (tagName.startsWith('beta-')) { testTag = 'beta'; } else if (tagName.startsWith('test-')) { testTag = 'test'; } return { version: release.tag_name, publishedAt: release.published_at, isPrerelease: release.prerelease, isTest: testTag !== null, testTag, isLatestStable: release.tag_name === latestStableTag, }; }); return { releases, rawReleases: data }; } catch (error) { console.error('Failed to fetch docker-homebridge releases:', error instanceof Error ? error.message : error); return { releases: [], rawReleases: [] }; } } }; StatusService = __decorate([ Injectable(), __param(0, Inject(HttpService)), __param(1, Inject(Logger)), __param(2, Inject(ConfigService)), __param(3, Inject(PluginsService)), __param(4, Inject(ServerService)), __param(5, Inject(HomebridgeIpcService)), __metadata("design:paramtypes", [HttpService, Logger, ConfigService, PluginsService, ServerService, HomebridgeIpcService]) ], StatusService); export { StatusService }; //# sourceMappingURL=status.service.js.map