253 lines
7.8 KiB
JavaScript
253 lines
7.8 KiB
JavaScript
/**
|
|
* Hardware sensors via sysfs hwmon + thermal zones.
|
|
*
|
|
* Enable: PEARDATA_SENSORS=1 (default on Linux when unset)
|
|
* Disable: PEARDATA_SENSORS=0
|
|
*/
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import os from 'os'
|
|
import { EventEmitter } from 'events'
|
|
import {
|
|
SAMPLE_INTERVAL_MS,
|
|
registerChart,
|
|
makeSensorTempChart,
|
|
makeThermalZoneChart,
|
|
} from '../../../shared/metrics.js'
|
|
import { readFileBuf } from '../../utils/fd-cache.js'
|
|
import logger from '../../utils/logger.js'
|
|
|
|
const log = logger.child('sensors')
|
|
|
|
export function isSensorsEnabled() {
|
|
const v = process.env.PEARDATA_SENSORS
|
|
if (v === '0' || v === 'off' || v === 'false') return false
|
|
if (v === '1' || v === 'on' || v === 'true') return true
|
|
return os.platform() === 'linux'
|
|
}
|
|
|
|
function readFile(p) {
|
|
return readFileBuf(p)
|
|
}
|
|
|
|
function makeSensorFanChart(chip, label) {
|
|
const id = `${chip}_${label}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
|
|
return {
|
|
id: `sensors.fan.${id}`,
|
|
name: `sensors.fan.${id}`,
|
|
context: 'sensors.fan',
|
|
title: `Fan ${chip} ${label}`,
|
|
units: 'RPM',
|
|
family: chip,
|
|
chartType: 'line',
|
|
priority: 7040,
|
|
plugin: 'sensors',
|
|
dimensions: [{ id: 'rpm', name: 'rpm', algorithm: 'absolute' }],
|
|
}
|
|
}
|
|
|
|
function makeSensorVoltageChart(chip, label) {
|
|
const id = `${chip}_${label}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
|
|
return {
|
|
id: `sensors.voltage.${id}`,
|
|
name: `sensors.voltage.${id}`,
|
|
context: 'sensors.voltage',
|
|
title: `Voltage ${chip} ${label}`,
|
|
units: 'mV',
|
|
family: chip,
|
|
chartType: 'line',
|
|
priority: 7050,
|
|
plugin: 'sensors',
|
|
dimensions: [{ id: 'millivolts', name: 'millivolts', algorithm: 'absolute' }],
|
|
}
|
|
}
|
|
|
|
function makeSensorPowerChart(chip, label) {
|
|
const id = `${chip}_${label}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 64)
|
|
return {
|
|
id: `sensors.power.${id}`,
|
|
name: `sensors.power.${id}`,
|
|
context: 'sensors.power',
|
|
title: `Power ${chip} ${label}`,
|
|
units: 'microWatts',
|
|
family: chip,
|
|
chartType: 'line',
|
|
priority: 7060,
|
|
plugin: 'sensors',
|
|
dimensions: [{ id: 'microwatts', name: 'microwatts', algorithm: 'absolute' }],
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns {Array<{ chartId: string, context: string, values: Record<string, number> }>}
|
|
*/
|
|
/**
|
|
* @returns {Array<{ chartId: string, context: string, values: Record<string, number> }>}
|
|
*/
|
|
export function sampleSensors() {
|
|
/** @type {Array<{ chartId: string, context: string, values: Record<string, number> }>} */
|
|
const out = []
|
|
|
|
for (const entry of getSensorPaths()) {
|
|
let raw
|
|
if (entry._type === 'temp') {
|
|
raw = readFile(entry._inputPath)
|
|
if (raw == null) continue
|
|
const milli = Number(raw.trim())
|
|
if (!Number.isFinite(milli)) continue
|
|
out.push({ chartId: entry.chartId, context: entry.context, values: { temperature: milli / 1000 } })
|
|
} else if (entry._type === 'fan') {
|
|
raw = readFile(entry._inputPath)
|
|
if (raw == null) continue
|
|
const rpm = Number(raw.trim())
|
|
if (!Number.isFinite(rpm)) continue
|
|
out.push({ chartId: entry.chartId, context: entry.context, values: { rpm } })
|
|
} else if (entry._type === 'volt') {
|
|
raw = readFile(entry._inputPath)
|
|
if (raw == null) continue
|
|
const mv = Number(raw.trim())
|
|
if (!Number.isFinite(mv)) continue
|
|
out.push({ chartId: entry.chartId, context: entry.context, values: { millivolts: mv } })
|
|
} else if (entry._type === 'power') {
|
|
raw = readFile(entry._inputPath)
|
|
if (raw == null) continue
|
|
const uw = Number(raw.trim())
|
|
if (!Number.isFinite(uw)) continue
|
|
out.push({ chartId: entry.chartId, context: entry.context, values: { microwatts: uw } })
|
|
} else if (entry._type === 'thermal') {
|
|
raw = readFile(entry._inputPath)
|
|
if (raw == null) continue
|
|
const milli = Number(raw.trim())
|
|
if (!Number.isFinite(milli) || milli === 0) continue
|
|
const celsius = milli > 1000 ? milli / 1000 : milli
|
|
out.push({ chartId: entry.chartId, context: entry.context, values: { temperature: celsius } })
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
let _sensorPaths = null
|
|
let _sensorPathsTs = 0
|
|
|
|
function getSensorPaths() {
|
|
const now = Date.now()
|
|
if (_sensorPaths && now - _sensorPathsTs < 30000) return _sensorPaths
|
|
_sensorPaths = []
|
|
_sensorPathsTs = now
|
|
|
|
const hwmonRoot = '/sys/class/hwmon'
|
|
try {
|
|
for (const chip of fs.readdirSync(hwmonRoot)) {
|
|
const dir = path.join(hwmonRoot, chip)
|
|
const name = (readFile(path.join(dir, 'name')) || chip).trim()
|
|
let ents
|
|
try {
|
|
ents = fs.readdirSync(dir)
|
|
} catch {
|
|
continue
|
|
}
|
|
for (const ent of ents) {
|
|
const m = ent.match(/^temp(\d+)_input$/)
|
|
if (m) {
|
|
const n = m[1]
|
|
const label = (readFile(path.join(dir, `temp${n}_label`)) || `temp${n}`).trim() || `temp${n}`
|
|
const def = makeSensorTempChart(name, label)
|
|
registerChart(def)
|
|
_sensorPaths.push({ chartId: def.id, context: def.context, _type: 'temp', _inputPath: path.join(dir, ent) })
|
|
continue
|
|
}
|
|
const fan = ent.match(/^fan(\d+)_input$/)
|
|
if (fan) {
|
|
const n = fan[1]
|
|
const label = (readFile(path.join(dir, `fan${n}_label`)) || `fan${n}`).trim() || `fan${n}`
|
|
const def = makeSensorFanChart(name, label)
|
|
registerChart(def)
|
|
_sensorPaths.push({ chartId: def.id, context: def.context, _type: 'fan', _inputPath: path.join(dir, ent) })
|
|
continue
|
|
}
|
|
const vin = ent.match(/^in(\d+)_input$/)
|
|
if (vin) {
|
|
const n = vin[1]
|
|
const label = (readFile(path.join(dir, `in${n}_label`)) || `in${n}`).trim() || `in${n}`
|
|
const def = makeSensorVoltageChart(name, label)
|
|
registerChart(def)
|
|
_sensorPaths.push({ chartId: def.id, context: def.context, _type: 'volt', _inputPath: path.join(dir, ent) })
|
|
continue
|
|
}
|
|
const pwr = ent.match(/^power(\d+)_input$/)
|
|
if (pwr) {
|
|
const n = pwr[1]
|
|
const label = (readFile(path.join(dir, `power${n}_label`)) || `power${n}`).trim() || `power${n}`
|
|
const def = makeSensorPowerChart(name, label)
|
|
registerChart(def)
|
|
_sensorPaths.push({ chartId: def.id, context: def.context, _type: 'power', _inputPath: path.join(dir, ent) })
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// no hwmon
|
|
}
|
|
|
|
const thermalRoot = '/sys/class/thermal'
|
|
try {
|
|
for (const zone of fs.readdirSync(thermalRoot)) {
|
|
if (!zone.startsWith('thermal_zone')) continue
|
|
const dir = path.join(thermalRoot, zone)
|
|
const type = (readFile(path.join(dir, 'type')) || zone).trim()
|
|
const def = makeThermalZoneChart(zone, type)
|
|
registerChart(def)
|
|
_sensorPaths.push({ chartId: def.id, context: def.context, _type: 'thermal', _inputPath: path.join(dir, 'temp') })
|
|
}
|
|
} catch {
|
|
// no thermal
|
|
}
|
|
|
|
return _sensorPaths
|
|
}
|
|
|
|
export class SensorsCollector extends EventEmitter {
|
|
constructor(opts = {}) {
|
|
super()
|
|
this.intervalMs =
|
|
opts.intervalMs ?? (Number(process.env.PEARDATA_SAMPLE_MS) || SAMPLE_INTERVAL_MS)
|
|
this.timer = null
|
|
this.running = false
|
|
}
|
|
|
|
start() {
|
|
if (this.running) return
|
|
this.running = true
|
|
this._tick()
|
|
this.timer = setInterval(() => this._tick(), this.intervalMs)
|
|
if (this.timer.unref) this.timer.unref()
|
|
log.info('Sensors collector started')
|
|
}
|
|
|
|
stop() {
|
|
this.running = false
|
|
if (this.timer) clearInterval(this.timer)
|
|
this.timer = null
|
|
}
|
|
|
|
_tick() {
|
|
const ts = Date.now()
|
|
const samples = sampleSensors()
|
|
if (!samples.length) return
|
|
const batch = samples.map((s) => ({
|
|
chart: s.chartId,
|
|
context: s.context,
|
|
ts,
|
|
values: s.values,
|
|
}))
|
|
this.emit('samples', batch)
|
|
}
|
|
}
|
|
|
|
/** @type {SensorsCollector|null} */
|
|
let singleton = null
|
|
|
|
export function getSensorsCollector() {
|
|
if (!singleton) singleton = new SensorsCollector()
|
|
return singleton
|
|
}
|