homebridge-config-ui-x
Version:
A web based management, configuration and control platform for Homebridge.
645 lines • 27.3 kB
JavaScript
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
return c > 3 && r && Object.defineProperty(target, key, r), r;
};
var __metadata = (this && this.__metadata) || function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
};
var __param = (this && this.__param) || function (paramIndex, decorator) {
return function (target, key) { decorator(target, key, paramIndex); }
};
import { 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