Skip to content

Commit c17cc14

Browse files
committed
feat: windows cpu power via LibreHardwareMonitor, explain the apple/detail dashes
Windows has no user-space RAPL — reading CPU energy MSRs needs a ring-0 driver we don't ship (that's what Scaphandre/WinPowerMonitor install). So instead of signing our own driver, read CPU power from LibreHardwareMonitor if it's running: query its WMI provider for the CPU-package power sensor and report power.cpu (Intel + AMD, no cgo). Absent, power.cpu says to run LHM. Also stop the silent CPU blank on Apple Pro/Max: IOReport and powermetrics both report 0 for the CPU/ANE channels there (verified on an M3 Max — powermetrics prints "CPU Power: 0 mW" with the clusters pegged), so power.cpu now reads "Pro/Max: no per-domain CPU power" and power.total still carries the SMC total. The host detail view renders the unavailable reason next to the dash now, clamped to width so a long reason can't wrap and break the panel.
1 parent 01f15da commit c17cc14

6 files changed

Lines changed: 98 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,26 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
77

88
## [Unreleased]
99

10+
## [2.3.1] - 2026-07-01
11+
12+
### Added
13+
- **Windows CPU power via LibreHardwareMonitor.** Windows exposes no RAPL to user
14+
space (reading the CPU energy MSRs needs a ring-0 driver Heimdall doesn't ship).
15+
When **LibreHardwareMonitor** is running, Heimdall now reads the CPU-package
16+
power sensor from its WMI provider (`root/LibreHardwareMonitor`) and reports it
17+
as `power.cpu` — Intel and AMD, no cgo. Absent, `power.cpu` reads
18+
`no RAPL on Windows — run LibreHardwareMonitor for CPU power`.
19+
20+
### Fixed
21+
- **Apple Pro/Max: `power.cpu` explains the dash.** On Pro/Max dies the CPU/ANE
22+
power channels read 0 in *both* IOReport and powermetrics (only the SMC
23+
whole-system total is real; base M-series expose the per-domain figure). Instead
24+
of a silent blank, `power.cpu` now reads Unavailable with `Pro/Max: no per-domain
25+
CPU power`. `power.total` still carries the real whole-machine draw.
26+
- **Host detail view now shows *why* a metric is dashed** — the Unavailable reason
27+
(e.g. the CPU-power notes above) renders next to the dash, clamped to width so it
28+
can't wrap or break the layout.
29+
1030
## [2.3.0] - 2026-07-01
1131

1232
### Changed

app/internal/helper/collect.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,16 @@ func assembleApplePower(cpu, gpu, ane, gpuUtil float64, ioOK bool, smcPkg float6
166166
out = append(out, powerMetric("power.total", sum))
167167
}
168168
}
169+
// Apple Pro/Max SoCs expose no per-domain CPU power — IOReport and powermetrics
170+
// both report 0 for the CPU/ANE channels (only the SMC whole-system total is
171+
// real). Base M-series populate it. Say why rather than leaving power.cpu a
172+
// silent blank; power.total still carries the real whole-machine figure.
173+
if ioOK && !hasName(out, "power.cpu") {
174+
out = append(out, domain.Metric{
175+
Name: "power.cpu", Status: domain.StatusUnavailable,
176+
Detail: "Pro/Max: no per-domain CPU power",
177+
})
178+
}
169179
// Apple Silicon is unified memory — there is no discrete VRAM to read. Report
170180
// gpu.vram as Unavailable-with-reason so the panel explains the dash instead
171181
// of silently omitting the metric.

app/internal/helper/collect_windows.go

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,56 @@ package helper
88
import (
99
"context"
1010
"os/exec"
11+
"strconv"
1112

1213
"heimdall/app/internal/domain"
1314
)
1415

15-
// windowsPrivileged reads CPU/zone temperature from WMI via PowerShell, matching
16-
// the helper's existing shell-out pattern (no new dependency). CPU power is not
17-
// available on Windows without a kernel driver (RAPL is inaccessible), so
18-
// power.cpu is reported Unavailable-with-reason; the GPU rail (nvidia-smi) still
19-
// comes through and power.total upstream is then the GPU alone.
16+
// windowsPrivileged reports CPU power and package temperature on Windows.
17+
//
18+
// Windows exposes no RAPL to user space — reading the CPU energy MSRs needs a
19+
// ring-0 driver, which Heimdall does not ship. So CPU power comes from a
20+
// driver-backed monitor the operator already runs: LibreHardwareMonitor publishes
21+
// CPU package power over WMI (root/LibreHardwareMonitor), which we read here (no
22+
// cgo, Intel + AMD). When it isn't running, power.cpu is Unavailable with a reason
23+
// pointing at how to enable it. Package temperature still comes from the ACPI
24+
// thermal zone via WMI.
2025
func windowsPrivileged(ctx context.Context) []domain.Metric {
21-
out := []domain.Metric{{Name: "power.cpu", Status: domain.StatusUnavailable, Detail: "no CPU power counter on Windows (RAPL inaccessible)"}}
22-
b, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command",
26+
var out []domain.Metric
27+
if w, ok := lhmCPUPackageWatts(ctx); ok {
28+
out = append(out, domain.Metric{Name: "power.cpu", Unit: "watts", Status: domain.StatusOK, Gauge: w, Detail: "CPU package (LibreHardwareMonitor)"})
29+
} else {
30+
out = append(out, domain.Metric{Name: "power.cpu", Status: domain.StatusUnavailable, Detail: "no RAPL on Windows — run LibreHardwareMonitor for CPU power"})
31+
}
32+
if b, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command",
2333
"Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature "+
24-
"| Select-Object -ExpandProperty CurrentTemperature").Output()
34+
"| Select-Object -ExpandProperty CurrentTemperature").Output(); err == nil {
35+
if c, ok := parseThermalZoneCelsius(string(b)); ok {
36+
out = append(out, domain.Metric{Name: "temp.pkg", Unit: "celsius", Status: domain.StatusOK, Gauge: c})
37+
}
38+
}
39+
return out
40+
}
41+
42+
// lhmCPUPackageWatts reads CPU package power from a running LibreHardwareMonitor
43+
// via its WMI provider. LHM ships a signed driver that reads the RAPL MSRs Windows
44+
// won't expose otherwise, and surfaces them as Sensor instances. Absent (0, false)
45+
// when LHM isn't running or exposes no CPU package power sensor.
46+
func lhmCPUPackageWatts(ctx context.Context) (float64, bool) {
47+
out, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command",
48+
"$s = Get-CimInstance -Namespace root/LibreHardwareMonitor -ClassName Sensor -ErrorAction SilentlyContinue "+
49+
"| Where-Object { $_.SensorType -eq 'Power' -and $_.Name -like '*Package*' } "+
50+
"| Select-Object -First 1 -ExpandProperty Value; if ($s) { $s }").Output()
2551
if err != nil {
26-
return out
52+
return 0, false
2753
}
28-
if c, ok := parseThermalZoneCelsius(string(b)); ok {
29-
out = append(out, domain.Metric{Name: "temp.pkg", Unit: "celsius", Status: domain.StatusOK, Gauge: c})
54+
line := firstNonEmptyLine(string(out))
55+
if line == "" {
56+
return 0, false
3057
}
31-
return out
58+
w, err := strconv.ParseFloat(line, 64)
59+
if err != nil || w <= 0 {
60+
return 0, false
61+
}
62+
return w, true
3263
}

app/internal/helper/smc_power_test.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@ func TestAssembleApplePower_SMCWinsOverPhantomIOReport(t *testing.T) {
2020
if m := got["power.gpu"]; m.Status != domain.StatusOK || m.Gauge != 0.1 {
2121
t.Errorf("power.gpu = %+v, want 0.1W from IOReport", m)
2222
}
23-
if _, ok := got["power.cpu"]; ok {
24-
t.Errorf("power.cpu should be absent when IOReport CPU energy is 0")
23+
// Pro/Max exposes no per-domain CPU power (IOReport and powermetrics both read
24+
// 0); say why rather than leaving a silent blank.
25+
if m := got["power.cpu"]; m.Status != domain.StatusUnavailable || m.Detail == "" {
26+
t.Errorf("power.cpu = %+v, want unavailable-with-reason on Pro/Max", m)
2527
}
2628
if m := got["gpu.util"]; m.Status != domain.StatusOK || m.Gauge != 5 {
2729
t.Errorf("gpu.util = %+v, want 5%%", m)

app/internal/tui/dashboard/detail.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,13 @@ func (m Model) detailBody(h domain.HostView, w int) []string {
236236
lab := label.Style().Render(fmt.Sprintf(" %-6s", d.lab))
237237
mm := pickMetric(byName, d.keys...)
238238
if mm.Status != domain.StatusOK {
239-
b.WriteString(lab + " " + m.nonOK(mm) + "\n")
239+
line := lab + " " + m.nonOK(mm)
240+
// Show why it's dashed (e.g. "Pro/Max: no per-domain CPU power") when
241+
// there's room; the whole view is clamped to width, so it can't wrap.
242+
if mm.Detail != "" && showDetail {
243+
line += " " + muted.Style().Render(mm.Detail)
244+
}
245+
b.WriteString(line + "\n")
240246
continue
241247
}
242248
gauge := render.Gauge(m.mode, mm.Gauge, gaugeCells)

docs/guides/16-privileged-windows.md

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,20 @@ and the GPU panel works **unprivileged** — same as
3232
**not** wired (the amdgpu sysfs path is Linux-only, and `amd-smi` parsing is not
3333
yet exercised on Windows).
3434

35-
## CPU package power
35+
## CPU power
3636

37-
`power.cpu` is **not available on Windows**. RAPL is reachable only through a
38-
signed kernel driver, which Heimdall does not ship — so CPU power stays
39-
`unavailable` rather than failing. This is a platform limit, not a
40-
misconfiguration.
37+
Windows exposes no RAPL to user space — reading the CPU energy MSRs needs a
38+
ring-0 driver, which Heimdall does not ship (that's exactly what tools like
39+
Scaphandre and WinPowerMonitor install). Rather than sign our own driver,
40+
Heimdall reads CPU power from a **driver-backed monitor you already run**:
41+
42+
- **[LibreHardwareMonitor](https://github.com/LibreHardwareMonitor/LibreHardwareMonitor)**
43+
— start it (its signed driver reads the RAPL MSRs on Intel **and** AMD) and
44+
leave "Remote Web Server / WMI" enabled. Heimdall queries its WMI provider
45+
(`root/LibreHardwareMonitor`) and reports the CPU-package sensor as `power.cpu`.
46+
47+
When no such monitor is running, `power.cpu` reads `unavailable` with
48+
`no RAPL on Windows — run LibreHardwareMonitor for CPU power` rather than failing.
4149

4250
## How the helper works on Windows
4351

@@ -53,7 +61,7 @@ daemon** can't. Here's what each Windows source needs:
5361
|---|---|---|
5462
| `nvidia-smi` (`gpu.util/vram/temp`, `power.gpu`) | normally **no** | richest panel on Windows when an NVIDIA GPU is present |
5563
| WMI `temp.pkg` (`MSAcpi_ThermalZoneTemperature`) | usually **yes** | only if the firmware exposes an ACPI thermal zone — many laptops don't |
56-
| `power.cpu` (RAPL) | | **not available** on Windows, no driver |
64+
| `power.cpu` | no (reads WMI) | needs **LibreHardwareMonitor** running (its driver reads RAPL); else `unavailable` |
5765

5866
`nvidia-smi` is normally unprivileged, so a daemon running as your user *should*
5967
collect the GPU panel without the helper. But account/PATH setups vary — don't

0 commit comments

Comments
 (0)