Skip to content

Commit f5b8070

Browse files
committed
feat(windows): read CPU power from Scaphandre instead of LibreHardwareMonitor
LibreHardwareMonitor is a GUI app — awkward to install and keep running on a headless box. Scaphandre is the tool the WinPowerMonitor ecosystem already uses: it installs the signed Hubblo RAPL driver and runs as a Windows service with a Prometheus endpoint. Heimdall now scrapes that (sum of scaph_socket_power_microwatts) and reports power.cpu — Intel + AMD, pure Go, no cgo. HEIMDALL_SCAPHANDRE_URL overrides the endpoint. Without it, power.cpu says to run Scaphandre. Windows guide + metrics doc updated.
1 parent c17cc14 commit f5b8070

6 files changed

Lines changed: 137 additions & 35 deletions

File tree

CHANGELOG.md

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

88
## [Unreleased]
99

10+
## [2.3.2] - 2026-07-01
11+
12+
### Changed
13+
- **Windows CPU power now comes from Scaphandre, not LibreHardwareMonitor.**
14+
Scaphandre installs the signed Hubblo RAPL driver and runs as a Windows service
15+
exposing a Prometheus endpoint — a far cleaner install than a GUI monitor.
16+
Heimdall scrapes it (`scaph_socket_power_microwatts`, summed) and reports
17+
`power.cpu` (Intel + AMD, pure Go, no cgo). Point it at a non-default endpoint
18+
with `HEIMDALL_SCAPHANDRE_URL`. Without Scaphandre, `power.cpu` reads
19+
`no RAPL on Windows — run Scaphandre for CPU power`. See guide 16.
20+
1021
## [2.3.1] - 2026-07-01
1122

1223
### Added

app/internal/helper/collect_windows.go

Lines changed: 34 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,34 @@ package helper
77

88
import (
99
"context"
10+
"io"
11+
"net/http"
12+
"os"
1013
"os/exec"
11-
"strconv"
14+
"time"
1215

1316
"heimdall/app/internal/domain"
1417
)
1518

19+
// scaphandreDefaultURL is Scaphandre's Prometheus exporter default. Override with
20+
// HEIMDALL_SCAPHANDRE_URL when it runs elsewhere.
21+
const scaphandreDefaultURL = "http://127.0.0.1:8080/metrics"
22+
1623
// windowsPrivileged reports CPU power and package temperature on Windows.
1724
//
1825
// 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.
26+
// ring-0 driver, which Heimdall does not ship. Scaphandre installs the signed
27+
// Hubblo RAPL driver and runs as a service exposing power over a Prometheus
28+
// endpoint; when it's up we scrape it and report CPU-package power as power.cpu
29+
// (Intel + AMD, pure Go, no cgo). When it isn't, power.cpu is Unavailable with a
30+
// reason pointing at it. Package temperature still comes from the ACPI thermal
31+
// zone via WMI.
2532
func windowsPrivileged(ctx context.Context) []domain.Metric {
2633
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)"})
34+
if w, ok := scaphandreCPUWatts(ctx); ok {
35+
out = append(out, domain.Metric{Name: "power.cpu", Unit: "watts", Status: domain.StatusOK, Gauge: w, Detail: "CPU package (Scaphandre)"})
2936
} else {
30-
out = append(out, domain.Metric{Name: "power.cpu", Status: domain.StatusUnavailable, Detail: "no RAPL on Windows — run LibreHardwareMonitor for CPU power"})
37+
out = append(out, domain.Metric{Name: "power.cpu", Status: domain.StatusUnavailable, Detail: "no RAPL on Windows — run Scaphandre for CPU power"})
3138
}
3239
if b, err := exec.CommandContext(ctx, "powershell", "-NoProfile", "-Command",
3340
"Get-CimInstance -Namespace root/wmi -ClassName MSAcpi_ThermalZoneTemperature "+
@@ -39,25 +46,28 @@ func windowsPrivileged(ctx context.Context) []domain.Metric {
3946
return out
4047
}
4148

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()
49+
// scaphandreCPUWatts scrapes a running Scaphandre Prometheus exporter for CPU
50+
// package power. Absent (0, false) when Scaphandre isn't reachable.
51+
func scaphandreCPUWatts(ctx context.Context) (float64, bool) {
52+
url := os.Getenv("HEIMDALL_SCAPHANDRE_URL")
53+
if url == "" {
54+
url = scaphandreDefaultURL
55+
}
56+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
57+
if err != nil {
58+
return 0, false
59+
}
60+
resp, err := (&http.Client{Timeout: 2 * time.Second}).Do(req)
5161
if err != nil {
5262
return 0, false
5363
}
54-
line := firstNonEmptyLine(string(out))
55-
if line == "" {
64+
defer resp.Body.Close()
65+
if resp.StatusCode != http.StatusOK {
5666
return 0, false
5767
}
58-
w, err := strconv.ParseFloat(line, 64)
59-
if err != nil || w <= 0 {
68+
b, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
69+
if err != nil {
6070
return 0, false
6171
}
62-
return w, true
72+
return parseScaphandreCPUWatts(string(b))
6373
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2026 Kinn Coelho Juliao <kinncj@gmail.com>
3+
4+
package helper
5+
6+
import (
7+
"strconv"
8+
"strings"
9+
)
10+
11+
// parseScaphandreCPUWatts sums Scaphandre's per-socket CPU power from its
12+
// Prometheus exposition and returns watts. Scaphandre reports
13+
// scaph_socket_power_microwatts per CPU socket (the RAPL package domain) in
14+
// microwatts; summing the sockets and dividing by 1e6 gives CPU-package power —
15+
// the Windows equivalent of the RAPL package read directly on Linux. No socket
16+
// line means no reading.
17+
func parseScaphandreCPUWatts(promText string) (float64, bool) {
18+
var micro float64
19+
found := false
20+
for _, ln := range strings.Split(promText, "\n") {
21+
ln = strings.TrimSpace(ln)
22+
if ln == "" || strings.HasPrefix(ln, "#") {
23+
continue
24+
}
25+
if !strings.HasPrefix(ln, "scaph_socket_power_microwatts") {
26+
continue
27+
}
28+
f := strings.Fields(ln)
29+
if len(f) < 2 {
30+
continue
31+
}
32+
v, err := strconv.ParseFloat(f[len(f)-1], 64)
33+
if err != nil {
34+
continue
35+
}
36+
micro += v
37+
found = true
38+
}
39+
if !found {
40+
return 0, false
41+
}
42+
return micro / 1e6, true
43+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
// Copyright (C) 2026 Kinn Coelho Juliao <kinncj@gmail.com>
3+
4+
package helper
5+
6+
import "testing"
7+
8+
// Scaphandre's Prometheus exporter reports per-socket CPU power in microwatts;
9+
// power.cpu is their sum in watts.
10+
func TestParseScaphandreCPUWatts(t *testing.T) {
11+
text := `# HELP scaph_socket_power_microwatts Power measured per CPU socket
12+
# TYPE scaph_socket_power_microwatts gauge
13+
scaph_host_power_microwatts 45000000
14+
scaph_socket_power_microwatts{socket_id="0"} 30000000
15+
scaph_socket_power_microwatts{socket_id="1"} 10000000
16+
`
17+
w, ok := parseScaphandreCPUWatts(text)
18+
if !ok || w != 40 { // (30M + 10M) µW ÷ 1e6 = 40 W
19+
t.Fatalf("got %v %v, want 40 W", w, ok)
20+
}
21+
22+
// Scientific notation (Scaphandre emits floats) is accepted.
23+
w, ok = parseScaphandreCPUWatts("scaph_socket_power_microwatts{socket_id=\"0\"} 1.25e7\n")
24+
if !ok || w != 12.5 {
25+
t.Fatalf("got %v %v, want 12.5 W", w, ok)
26+
}
27+
28+
// No socket metric present → no reading (not a phantom 0).
29+
if _, ok := parseScaphandreCPUWatts("# nothing here\nscaph_host_power_microwatts 1000000\n"); ok {
30+
t.Error("no socket power metric should yield no reading")
31+
}
32+
}

docs/guides/16-privileged-windows.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,17 +35,23 @@ yet exercised on Windows).
3535
## CPU power
3636

3737
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**:
38+
ring-0 driver, which Heimdall does not ship. Rather than sign our own driver,
39+
Heimdall reads CPU power from **[Scaphandre](https://github.com/hubblo-org/scaphandre)**,
40+
which installs the signed Hubblo RAPL driver and runs as a Windows service.
4141

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`.
42+
Set it up once:
4643

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.
44+
1. Download **Scaphandre ≥ 1.0.0** for Windows (its installer bundles the Hubblo
45+
RAPL driver — Intel and AMD since Ryzen).
46+
2. Run the Prometheus exporter, e.g. `scaphandre prometheus` (default
47+
`http://127.0.0.1:8080/metrics`), ideally as a service so it starts at boot.
48+
49+
Heimdall scrapes that endpoint and reports the summed per-socket power
50+
(`scaph_socket_power_microwatts`) as `power.cpu` — pure Go, no cgo. If Scaphandre
51+
listens elsewhere, point Heimdall at it with `HEIMDALL_SCAPHANDRE_URL`.
52+
53+
When Scaphandre isn't reachable, `power.cpu` reads `unavailable` with
54+
`no RAPL on Windows — run Scaphandre for CPU power` rather than failing.
4955

5056
## How the helper works on Windows
5157

@@ -61,7 +67,7 @@ daemon** can't. Here's what each Windows source needs:
6167
|---|---|---|
6268
| `nvidia-smi` (`gpu.util/vram/temp`, `power.gpu`) | normally **no** | richest panel on Windows when an NVIDIA GPU is present |
6369
| WMI `temp.pkg` (`MSAcpi_ThermalZoneTemperature`) | usually **yes** | only if the firmware exposes an ACPI thermal zone — many laptops don't |
64-
| `power.cpu` | no (reads WMI) | needs **LibreHardwareMonitor** running (its driver reads RAPL); else `unavailable` |
70+
| `power.cpu` | no (scrapes HTTP) | needs **Scaphandre** running (installs the RAPL driver, exposes Prometheus); else `unavailable` |
6571

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

docs/metrics.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ rather than failing. See [Privileged Metrics](guides/04-privileged-metrics.md).
7272
| `temp.pkg` | °C | CPU package temperature |
7373
| `gpu.temp` | °C | GPU temperature |
7474
| `power.total` | W | whole-machine power — `cpu + gpu (+ npu)`, or the SMC whole-system total on macOS |
75-
| `power.cpu` | W | CPU power (RAPL package on Linux; IOReport CPU on macOS) |
75+
| `power.cpu` | W | CPU power RAPL package (Linux), IOReport CPU (macOS), Scaphandre (Windows, when running); Unavailable-with-reason where no source exists (Apple Pro/Max, Windows without Scaphandre, GB10) |
7676
| `power.gpu` | W | GPU power |
7777
| `power.npu` | W | NPU / accelerator power (Apple Silicon ANE today). The legacy `power.ane` key is accepted and normalised to `power.npu` on ingest. |
7878
| `npu.util` | percent | NPU utilisation (where the platform exposes it; otherwise unavailable) |

0 commit comments

Comments
 (0)