Files
peardata/server/services/collectors/sensors.js
T
Raven Scott 2e1a3e9b06
CI / test (push) Successful in 1m24s
Release rolling / release (push) Has been cancelled
Updates
2026-07-18 19:44:32 -04:00

231 lines
6.7 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 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) {
try {
return fs.readFileSync(p, 'utf8')
} catch {
return null
}
}
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> }>}
*/
export function sampleSensors() {
/** @type {Array<{ chartId: string, context: string, values: Record<string, number> }>} */
const out = []
const hwmonRoot = '/sys/class/hwmon'
try {
const chips = fs.readdirSync(hwmonRoot)
for (const chip of chips) {
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) continue
const n = m[1]
const raw = readFile(path.join(dir, ent))
if (raw == null) continue
const milli = Number(raw.trim())
if (!Number.isFinite(milli)) continue
const label =
(readFile(path.join(dir, `temp${n}_label`)) || `temp${n}`).trim() || `temp${n}`
const def = makeSensorTempChart(name, label)
registerChart(def)
out.push({
chartId: def.id,
context: def.context,
values: { temperature: milli / 1000 },
})
}
const fan = ent.match(/^fan(\d+)_input$/)
if (fan) {
const n = fan[1]
const raw = readFile(path.join(dir, ent))
if (raw == null) continue
const rpm = Number(raw.trim())
if (!Number.isFinite(rpm)) continue
const label =
(readFile(path.join(dir, `fan${n}_label`)) || `fan${n}`).trim() || `fan${n}`
const def = makeSensorFanChart(name, label)
registerChart(def)
out.push({ chartId: def.id, context: def.context, values: { rpm } })
}
const vin = ent.match(/^in(\d+)_input$/)
if (vin) {
const n = vin[1]
const raw = readFile(path.join(dir, ent))
if (raw == null) continue
const mv = Number(raw.trim())
if (!Number.isFinite(mv)) continue
const label =
(readFile(path.join(dir, `in${n}_label`)) || `in${n}`).trim() || `in${n}`
const def = makeSensorVoltageChart(name, label)
registerChart(def)
out.push({ chartId: def.id, context: def.context, values: { millivolts: mv } })
}
const pwr = ent.match(/^power(\d+)_input$/)
if (pwr) {
const n = pwr[1]
const raw = readFile(path.join(dir, ent))
if (raw == null) continue
const uw = Number(raw.trim())
if (!Number.isFinite(uw)) continue
const label =
(readFile(path.join(dir, `power${n}_label`)) || `power${n}`).trim() || `power${n}`
const def = makeSensorPowerChart(name, label)
registerChart(def)
out.push({ chartId: def.id, context: def.context, values: { microwatts: uw } })
}
}
} 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 raw = readFile(path.join(dir, 'temp'))
if (raw == null) continue
const milli = Number(raw.trim())
if (!Number.isFinite(milli) || milli === 0) continue
// some platforms report already in C
const celsius = milli > 1000 ? milli / 1000 : milli
const def = makeThermalZoneChart(zone, type)
registerChart(def)
out.push({ chartId: def.id, context: def.context, values: { temperature: celsius } })
}
} catch {
// no thermal
}
return out
}
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
}