hb-lib-tools
Version:
homebridge-lib Command-Line Tools`
539 lines (520 loc) • 15.7 kB
JavaScript
// hb-lib-tools/lib/SystemInfo.js
//
// Library for Homebridge plugins.
// Copyright © 2019-2025 Erik Baauw. All rights reserved.
import { EventEmitter } from 'node:events'
import { exec, execFile } from 'node:child_process'
import { access, readFile } from 'node:fs/promises'
import { cpus } from 'node:os'
import { toHexString } from 'hb-lib-tools'
import { semver } from 'hb-lib-tools/semver'
const rpiInfo = {
manufacturers: {
0: 'Sony UK',
1: 'Egoman',
2: 'Embest',
3: 'Sony Japan',
4: 'Embest',
5: 'Stadium'
},
memorySizes: {
0: '256MB',
1: '512MB',
2: '1GB',
3: '2GB',
4: '4GB',
5: '8GB',
6: '16GB'
},
models: {
0: 'A',
1: 'B',
2: 'A+',
3: 'B+',
4: '2B',
5: 'Alpha', // early prototype
6: 'CM1',
8: '3B',
9: 'Zero',
10: 'CM3',
12: 'Zero W',
13: '3B+',
14: '3A+',
// 15: '', // Internal use only
16: 'CM3+',
17: '4B',
18: 'Zero 2 W',
19: '400',
20: 'CM4',
21: 'CM4S',
// 22: '', // Internal use only
23: '5',
24: 'CM5',
25: '500',
26: 'CM5 Lite'
},
processors: {
0: 'BCM2835',
1: 'BCM2836',
2: 'BCM2837',
3: 'BCM2711',
4: 'BCM2712'
},
oldRevisions: {
2: { model: 'B', revision: '1.0', memory: '256MB', manufacturer: 'Egoman' },
3: { model: 'B', revision: '1.0', memory: '256MB', manufacturer: 'Egoman' },
4: { model: 'B', revision: '2.0', memory: '256MB', manufacturer: 'Sony UK' },
5: { model: 'B', revision: '2.0', memory: '256MB', manufacturer: 'Qisda' },
6: { model: 'B', revision: '2.0', memory: '256MB', manufacturer: 'Egoman' },
7: { model: 'A', revision: '2.0', memory: '256MB', manufacturer: 'Egoman' },
8: { model: 'A', revision: '2.0', memory: '256MB', manufacturer: 'Sony UK' },
9: { model: 'A', revision: '2.0', memory: '256MB', manufacturer: 'Qisda' },
13: { model: 'B', revision: '2.0', memory: '512MB', manufacturer: 'Egoman' },
14: { model: 'B', revision: '2.0', memory: '512MB', manufacturer: 'Sony UK' },
15: { model: 'B', revision: '2.0', memory: '512MB', manufacturer: 'Egoman' },
16: { model: 'B+', revision: '1.2', memory: '512MB', manufacturer: 'Sony UK' },
17: { model: 'CM1', revision: '1.0', memory: '512MB', manufacturer: 'Sony UK' },
18: { model: 'A+', revision: '1.1', memory: '256MB', manufacturer: 'Sony UK' },
19: { model: 'B+', revision: '1.2', memory: '512MB', manufacturer: 'Embest' },
20: { model: 'CM1', revision: '1.0', memory: '512MB', manufacturer: 'Embest' },
21: { model: 'A+', revision: '1.1', memory: '256MB/512MB', manufacturer: 'Embest' }
}
}
// See: https://en.wikipedia.org/wiki/MacOS_version_history
const macOsInfo = {
versionNames: {
'10.0': 'Cheetah',
10.1: 'Puma',
10.2: 'Jaguar',
10.3: 'Panther',
10.4: 'Tiger',
10.5: 'Leopard',
10.6: 'Snow Leopard',
10.7: 'Lion',
10.8: 'Mountain Lion',
10.9: 'Mavericks',
'10.10': 'Yosemite',
10.11: 'El Capitan',
10.12: 'Sierra',
10.13: 'High Sierra',
10.14: 'Mojave',
10.15: 'Catalina',
11: 'Big Sur',
12: 'Monterey',
13: 'Ventura',
14: 'Sonoma',
15: 'Sequoia'
}
}
/** System information.
* <br>See {@link SystemInfo}.
* @name SystemInfo
* @type {Class}
* @memberof module:hb-lib-tools
*/
/** System information.
* @extends EventEmitter
*/
class SystemInfo extends EventEmitter {
/** Extract Raspberry Pi serial number and hardware revision info from the
* contents of `/proc/cpuinfo`.
* @param {string} cpuInfo - The contents of `/proc/cpuinfo`.
* @return {object} - The extracted info.
*/
static parseRpiCpuInfo (cpuInfo) {
let a = /Serial\s*: ([0-9a-f]{16})/.exec(cpuInfo)
if (a == null || a.length < 2) {
return null
}
const id = a[1].toUpperCase()
a = /Revision\s*: ([0-9a-f]{4,})/.exec(cpuInfo)
if (a == null || a.length < 2) {
return null
}
const revision = parseInt(a[1], 16) & 0x00FFFFFF
let gpioMask, manufacturer, memory, model, modelRevision, processor
if ((revision & 0x00800000) !== 0) { // New revision scheme.
manufacturer = rpiInfo.manufacturers[(revision & 0x000F0000) >> 16]
memory = rpiInfo.memorySizes[(revision & 0x00700000) >> 20]
model = rpiInfo.models[(revision & 0x00000FF0) >> 4]
modelRevision = '1.' + ((revision & 0x0000000F) >> 0).toString()
processor = rpiInfo.processors[(revision & 0x0000F000) >> 12]
} else if (rpiInfo.oldRevisions[revision] != null) { // Old incremental revisions.
manufacturer = rpiInfo.oldRevisions[revision].manufacturer
memory = rpiInfo.oldRevisions[revision].memory
model = rpiInfo.oldRevisions[revision].model
modelRevision = rpiInfo.oldRevisions[revision].revision
processor = 'BCM2835'
}
if (model?.startsWith('CM')) {
// Compute module
gpioMask = 0xFFFFFFFF // 0-31
} else if (revision >= 16) {
// Type 3
gpioMask = 0x0FFFFFFC // 2-27
} else if (revision >= 4) {
// Type 2
gpioMask = 0xFBC6CF9C // 2-4, 7-11, 14-15, 17-18, 22-25, 27-31
} else {
// Type 1
gpioMask = 0x03E6CF93 // 0-1, 4, 7-11, 14-15, 17-18, 21-25
}
return {
gpioMask,
gpioMaskSerial: (1 << 15) | (1 << 14),
id,
isRpi: true,
manufacturer,
memory,
model,
modelRevision,
prettyName: [
'Raspberry Pi', model, modelRevision, '(' + memory + ')'
].join(' '),
processor,
supportsFan: ['5'].includes(model),
supportsPowerLed: !(['A', 'B', 'Zero', 'Zero W', 'Zero 2 W', '5'].includes(model)),
supportsUsbPower: ['B+', '2B', '3B', '3B+'].includes(model),
revision: toHexString(revision, 6)
}
}
/** Initialise SystemInfo instance.
*/
async init () {
switch (process.platform) {
case 'linux':
if (await this.existsFile('/etc/synoinfo.conf')) {
try {
this.hwInfo = await this.getSynoInfo()
} catch (error) { this.emit('error', error) }
try {
this.osInfo = await this.getDsmInfo()
} catch (error) { this.emit('error', error) }
} else {
if (['arm', 'arm64'].includes(process.arch)) {
try {
this.hwInfo = await this.getRpiInfo()
} catch (error) { this.emit('error', error) }
}
try {
this.osInfo = await this.getPiOsInfo()
} catch (error) { this.emit('error', error) }
}
break
case 'darwin':
try {
this.hwInfo = await this.getMacInfo()
} catch (error) { this.emit('error', error) }
try {
this.osInfo = await this.getMacOsInfo()
} catch (error) { this.emit('error', error) }
break
default:
break
}
if (this.osInfo == null) {
this.osInfo = {
name: process.platform,
platform: process.platform,
prettyName: process.platform
}
}
if (this.hwInfo == null) {
this.hwInfo = {
nCores: cpus().length,
prettyName: process.arch,
processor: process.arch
}
}
this.platform = this.osInfo.platform
}
/** Extract serial number and hardware revision info from `/proc/cpuinfo`.
* @return {object} - The extracted info.
*/
async getRpiInfo () {
const cpuInfo = await this.readTextFile('/proc/cpuinfo')
return SystemInfo.parseRpiCpuInfo(cpuInfo)
}
/** Extract OS info from /etc/os-release.
* @return {object} - The extracted info.
*/
async getPiOsInfo () {
let name, platform, prettyName, version, versionName
const bit = (await this.exec('getconf', 'LONG_BIT')).trim()
const text = await this.readTextFile('/etc/os-release')
const lines = text.replace(/"/g, '').split('\n')
for (const line of lines) {
const fields = line.split('=')
if (fields.length === 2) {
switch (fields[0]) {
case 'ID':
platform = fields[1] // e.g. 'raspbian'
break
case 'NAME':
name = fields[1] // e.g. 'Raspbian GNU/Linux'
break
case 'PRETTY_NAME':
prettyName = fields[1] + ' [' + bit + ' bit]' // e.g. 'Raspbian GNU/Linux 11 (bullseye)'
break
case 'VERSION_CODENAME':
versionName = fields[1] // e.g. 'bullseye'
break
case 'VERSION_ID':
version = fields[1] // e.g. '11'
break
default:
break
}
}
}
return { name, platform, prettyName, version, versionName }
}
/** Extract Apple Mac hardware info from `system_profiler` command.
* @return {object} - The extracted info.
*/
async getMacInfo () {
let id, memory, model, nCores, prettyName, processor, revision
let text = await this.exec('system_profiler', 'SPHardwareDataType')
const lines = text.split('\n')
for (const line of lines) {
const fields = line.split(': ')
if (fields.length === 2) {
switch (fields[0].trim()) {
case 'Memory':
memory = fields[1].replace(/ /g, '')
break
case 'Model Identifier':
revision = fields[1]
break
case 'Model Name':
model = fields[1]
break
case 'Chip':
case 'Processor Name':
processor = fields[1]
break
case 'Serial Number (system)':
id = fields[1]
break
case 'Total Number of Cores':
nCores = fields[1].split(' ')[0]
break
default:
break
}
}
}
try {
if (process.arch === 'x64') { // Intel
text = await this.exec(
'plutil', '-p',
process.env.HOME + '/Library/Preferences/com.apple.SystemProfiler.plist'
)
const regexp = RegExp(
'"(' + id.slice(-4) + '|' + id.slice(-3) + ').*" => "(.*)"'
)
const a = regexp.exec(text)
if (a != null) {
prettyName = a[2]
}
} else { // Apple silicon
text = await this.execShell('ioreg -l | grep product-description')
const a = /"product-description" = <"([^"]*)">/.exec(text)
if (a != null) {
prettyName = a[1]
}
}
} catch (error) {
this.emit('error', error)
}
return {
id,
isMac: true,
manufacturer: 'Apple Inc.',
memory,
model,
nCores,
prettyName: prettyName || model,
processor,
revision
}
}
/** Extract macOS info from `sw_vers` command.
* @return {object} - The extracted info.
*/
async getMacOsInfo () {
let name, version, build
const text = await this.exec('sw_vers')
const lines = text.split('\n')
for (const line of lines) {
const fields = line.split(':')
if (fields.length === 2) {
switch (fields[0]) {
case 'ProductName': // e.g. 'macOS' or 'Mac OS X'
name = fields[1].trim()
break
case 'ProductVersion': // e.g. '12.0.1' or '12.1'
version = fields[1].trim()
if (version.split('.').length === 2) {
version += '.0'
}
break
case 'BuildVersion': // e.g. '21A559'
build = fields[1].trim()
break
default:
break
}
}
}
let v = semver.major(version)
if (v === 10) {
v += '.' + semver.minor(version)
}
const versionName = macOsInfo.versionNames[v] // e.g. 'Monterey'
return {
build,
catalina: semver.gte(version, '10.15.0'),
name,
platform: process.platform,
prettyName: [name, versionName, version, '(' + build + ')'].join(' '),
version,
versionName
}
}
/** Extract Synology info from `/etc/synoinfo.conf`
* @return {object} - The extracted info.
*/
async getSynoInfo () {
let device = ''
let id
let model = ''
const text = await this.readTextFile('/etc/synoinfo.conf')
const lines = text.replace(/"/g, '').split('\n')
for (const line of lines) {
const fields = line.split('=')
if (fields.length === 2) {
switch (fields[0].trim()) {
case 'pushservice_dsserial':
id = fields[1] // e.g 1970PDN255608
break
case 'upnpdevicetype':
device = fields[1] // e.g. DiskStation
break
case 'upnpmodelname':
model = fields[1] // e.g. DS918+
break
default:
break
}
}
}
return {
id,
manufacturer: 'Synology',
model: [device, model].join(' '),
prettyName: ['Synology', device, model].join(' ')
}
}
/** Extract DSM info from `/etc/VERSION`.
* @return {object} - The extracted info.
*/
async getDsmInfo () {
let build, prettyName, update, version
const text = await this.readTextFile('/etc/VERSION')
const lines = text.replace(/"/g, '').split('\n')
for (const line of lines) {
const fields = line.split('=')
if (fields.length === 2) {
switch (fields[0].trim()) {
case 'buildnumber':
build = fields[1] // e.g. 42661
break
case 'productversion':
version = fields[1] // e.g. 7.1
break
case 'smallfixnumber':
update = fields[1] // e.g. 3
break
default:
break
}
}
}
prettyName = 'DSM'
if (version != null) {
prettyName += ' ' + version
if (build != null) {
prettyName += '-' + build
}
if (update != null && update !== 0) {
prettyName += ' Update ' + update
}
}
return {
build,
prettyName,
update,
version
}
}
/** Execute a command on the local machine.
* @param {string} command - The command.
* @param {...string} ...args - The command parameters.
* @return {string} - The output of the command.
*/
async exec (command, ...args) {
return new Promise((resolve, reject) => {
/** Emitted when a command is executed.
* @event SystemInfo#exec
* @param {string} command - The command.
*/
this.emit('exec', command + ' ' + args.join(' '))
execFile(command, args, null, (error, stdout, stderr) => {
if (error != null) {
reject(error)
}
resolve(stdout)
})
})
}
/** Execute a shell command on the local machine.
* @param {string} command - The command.
* @return {string} - The output of the command.
*/
async execShell (command) {
return new Promise((resolve, reject) => {
this.emit('exec', command)
exec(command, (error, stdout, stderr) => {
if (error != null) {
reject(error)
}
resolve(stdout)
})
})
}
/** Check if file exists.
* @param {string} fileName - The file name.
* @return {bool} - True iff file exists,
*/
async existsFile (fileName) {
try {
await access(fileName)
return true
} catch (error) {}
return false
}
/** Read a text file.
* @param {string} fileName - The file name.
* @return {string} - The contents of the file.
*/
async readTextFile (fileName) {
/** Emitted when a file is read.
* @event SystemInfo#readFile
* @param {string} fileName - The file name.
*/
this.emit('readFile', fileName)
return readFile(fileName, 'utf8')
}
}
export { SystemInfo }