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