import { readFileSync, readdirSync } from "node:fs";import { Effect } from "effect";import type { ThermalInfo } from "../contracts";import { neverFails, type DeviceProbe } from "./probe"; const HWMON_ROOT = "/sys/class/hwmon"; /** hwmon reports millidegrees; anything outside a plausible range is a stuck sensor. */const MIN_PLAUSIBLE_C = 1;const MAX_PLAUSIBLE_C = 150; const readText = (path: string): string | null => { try { return readFileSync(path, "utf8").trim(); } catch { return null; }}; const parseCelsius = (raw: string | null): number | null => { if (raw === null) return null; const millidegrees = Number(raw); if (!Number.isFinite(millidegrees)) return null; const celsius = millidegrees / 1000; return celsius >= MIN_PLAUSIBLE_C && celsius <= MAX_PLAUSIBLE_C ? Math.round(celsius) : null;}; const sourceFor = (chip: string): ThermalInfo["source"] => { if (/^(?:coretemp|k10temp|zenpower|cpu)/i.test(chip)) return "cpu"; if (/^(?:amdgpu|nouveau|i915|xe)/i.test(chip)) return "gpu"; return "chassis";}; const readChip = (directory: string): readonly ThermalInfo[] => { const chip = readText(`${HWMON_ROOT}/${directory}/name`) ?? directory; const entries = ((): string[] => { try { return readdirSync(`${HWMON_ROOT}/${directory}`); } catch { return [] as string[]; } })(); const readings: ThermalInfo[] = []; for (const entry of entries) { if (!/^temp\d+_input$/.test(entry)) continue; const celsius = parseCelsius(readText(`${HWMON_ROOT}/${directory}/${entry}`)); if (celsius === null) continue; const label = readText(`${HWMON_ROOT}/${directory}/${entry.replace("_input", "_label")}`); readings.push({ label: label ? `${chip} ${label}` : chip, celsius, source: sourceFor(chip) }); } return readings;}; /** * Linux CPU and chassis sensors. GPU temperature comes from the vendor tool instead, so * this probe never claims the "temperature" capability for accelerators — only for the * host readings it actually produces. */export const thermalProbe: DeviceProbe = { id: "thermal", detect: (host) => host.platform === "linux", run: () => neverFails( Effect.sync(() => { const directories = ((): string[] => { try { return readdirSync(HWMON_ROOT); } catch { return [] as string[]; } })(); const thermals = directories.flatMap(readChip); return { fragment: { thermals }, capabilities: thermals.length > 0 ? ["temperature" as const] : [], }; }), ),};
import { readFileSync, readdirSync } from "node:fs";
import { Effect } from "effect";
import type { ThermalInfo } from "../contracts";
import { neverFails, type DeviceProbe } from "./probe";
const HWMON_ROOT = "/sys/class/hwmon";
/** hwmon reports millidegrees; anything outside a plausible range is a stuck sensor. */
const MIN_PLAUSIBLE_C = 1;
const MAX_PLAUSIBLE_C = 150;
const readText = (path: string): string | null => {
try {
return readFileSync(path, "utf8").trim();
} catch {
return null;
}
};
const parseCelsius = (raw: string | null): number | null => {
if (raw === null) return null;
const millidegrees = Number(raw);
if (!Number.isFinite(millidegrees)) return null;
const celsius = millidegrees / 1000;
return celsius >= MIN_PLAUSIBLE_C && celsius <= MAX_PLAUSIBLE_C ? Math.round(celsius) : null;
};
const sourceFor = (chip: string): ThermalInfo["source"] => {
if (/^(?:coretemp|k10temp|zenpower|cpu)/i.test(chip)) return "cpu";
if (/^(?:amdgpu|nouveau|i915|xe)/i.test(chip)) return "gpu";
return "chassis";
};
const readChip = (directory: string): readonly ThermalInfo[] => {
const chip = readText(`${HWMON_ROOT}/${directory}/name`) ?? directory;
const entries = ((): string[] => {
try {
return readdirSync(`${HWMON_ROOT}/${directory}`);
} catch {
return [] as string[];
}
})();
const readings: ThermalInfo[] = [];
for (const entry of entries) {
if (!/^temp\d+_input$/.test(entry)) continue;
const celsius = parseCelsius(readText(`${HWMON_ROOT}/${directory}/${entry}`));
if (celsius === null) continue;
const label = readText(`${HWMON_ROOT}/${directory}/${entry.replace("_input", "_label")}`);
readings.push({ label: label ? `${chip} ${label}` : chip, celsius, source: sourceFor(chip) });
}
return readings;
};
/**
* Linux CPU and chassis sensors. GPU temperature comes from the vendor tool instead, so
* this probe never claims the "temperature" capability for accelerators — only for the
* host readings it actually produces.
*/
export const thermalProbe: DeviceProbe = {
id: "thermal",
detect: (host) => host.platform === "linux",
run: () =>
neverFails(
Effect.sync(() => {
const directories = ((): string[] => {
try {
return readdirSync(HWMON_ROOT);
} catch {
return [] as string[];
}
})();
const thermals = directories.flatMap(readChip);
return {
fragment: { thermals },
capabilities: thermals.length > 0 ? ["temperature" as const] : [],
};
}),
),
};