/** * 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 }>} */ /** * @returns {Array<{ chartId: string, context: string, values: Record }>} */ export function sampleSensors() { /** @type {Array<{ chartId: string, context: string, values: Record }>} */ 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 }