The laptop model's hardware module and a memory-pressure module for any machine (hq research 027/03, 026/05, to-be 42 phase 3). The predecessor's polling auto-profile and mem-guard user scripts become each module's own Go code launched by the node runtime (ADR 0198): a profile switcher woken by the kernel's power-supply uevents, and a guard that warns on RAM, swap or PSI before systemd-oomd acts, on the desktop over the account's bus and always as an event. supergfxctl and triggerhappy are kept as found (research 027 Q1).
169 lines
4.9 KiB
Go
169 lines
4.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// Sensor is one temperature, fan or power reading from hwmon.
|
|
type Sensor struct {
|
|
Chip string `json:"chip"`
|
|
Label string `json:"label"`
|
|
Value float64 `json:"value"`
|
|
}
|
|
|
|
// DGPU is the discrete GPU as the PCI bus and its driver see it.
|
|
type DGPU struct {
|
|
Address string `json:"pci_address"`
|
|
Runtime string `json:"runtime_status"`
|
|
Name string `json:"name,omitempty"`
|
|
TempC *float64 `json:"temp_c,omitempty"`
|
|
PowerW *float64 `json:"power_w,omitempty"`
|
|
PState string `json:"pstate,omitempty"`
|
|
Note string `json:"note,omitempty"`
|
|
}
|
|
|
|
// hwmon reads every hwmon reading of one kind: "temp" (°C), "fan" (RPM) or "power" (W).
|
|
func (m *Machine) hwmon(kind string) []Sensor {
|
|
var out []Sensor
|
|
for _, dir := range m.glob("/sys/class/hwmon/hwmon*") {
|
|
chip := m.read(dir + "/name")
|
|
inputs := m.glob(dir + "/" + kind + "*_input")
|
|
if kind == "power" {
|
|
inputs = append(inputs, m.glob(dir+"/power*_average")...)
|
|
}
|
|
for _, in := range inputs {
|
|
v, ok := m.readInt(in)
|
|
if !ok {
|
|
continue
|
|
}
|
|
base := path.Base(in)
|
|
stem := base[:strings.LastIndex(base, "_")]
|
|
label := m.read(dir + "/" + stem + "_label")
|
|
if label == "" {
|
|
label = base
|
|
} else if strings.HasSuffix(base, "_average") {
|
|
label += " (average)"
|
|
}
|
|
value := float64(v)
|
|
switch kind {
|
|
case "temp":
|
|
value = round1(value / 1000)
|
|
case "power":
|
|
value = round1(value / 1e6)
|
|
}
|
|
out = append(out, Sensor{Chip: chip, Label: label, Value: value})
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Chip != out[j].Chip {
|
|
return out[i].Chip < out[j].Chip
|
|
}
|
|
return out[i].Label < out[j].Label
|
|
})
|
|
return out
|
|
}
|
|
|
|
// dgpu finds the NVIDIA display controller and, only when it is already awake, asks its driver for
|
|
// its temperature and draw. **Asking wakes it**: nvidia-smi brings a suspended GPU out of D3, which
|
|
// is the power a reading of power draw should not cost.
|
|
func (m *Machine) dgpu(ctx context.Context) *DGPU {
|
|
for _, dir := range m.glob("/sys/bus/pci/devices/*") {
|
|
if m.read(dir+"/vendor") != "0x10de" || !strings.HasPrefix(m.read(dir+"/class"), "0x03") {
|
|
continue
|
|
}
|
|
g := &DGPU{Address: path.Base(dir), Runtime: m.read(dir + "/power/runtime_status")}
|
|
if g.Runtime != "active" {
|
|
g.Note = "the discrete GPU is " + g.Runtime + "; not woken to be read"
|
|
return g
|
|
}
|
|
out, err := m.Run(ctx, "nvidia-smi", "--query-gpu=name,temperature.gpu,power.draw,pstate", "--format=csv,noheader,nounits")
|
|
if err != nil {
|
|
g.Note = "nvidia-smi: " + err.Error()
|
|
return g
|
|
}
|
|
f := strings.Split(strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]), ",")
|
|
if len(f) >= 4 {
|
|
g.Name = strings.TrimSpace(f[0])
|
|
if t, err := strconv.ParseFloat(strings.TrimSpace(f[1]), 64); err == nil {
|
|
g.TempC = &t
|
|
}
|
|
if w, err := strconv.ParseFloat(strings.TrimSpace(f[2]), 64); err == nil {
|
|
w = round1(w)
|
|
g.PowerW = &w
|
|
}
|
|
g.PState = strings.TrimSpace(f[3])
|
|
}
|
|
return g
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Thermals is what the thermals tool answers.
|
|
type Thermals struct {
|
|
Temperatures []Sensor `json:"temperatures_c"`
|
|
Fans []Sensor `json:"fans_rpm"`
|
|
DGPU *DGPU `json:"dgpu,omitempty"`
|
|
Profile string `json:"platform_profile,omitempty"`
|
|
Hottest *Sensor `json:"hottest,omitempty"`
|
|
}
|
|
|
|
func (m *Machine) Thermals(ctx context.Context) Thermals {
|
|
t := Thermals{Temperatures: m.hwmon("temp"), Fans: m.hwmon("fan"), DGPU: m.dgpu(ctx),
|
|
Profile: m.read("/sys/firmware/acpi/platform_profile")}
|
|
if t.Temperatures == nil {
|
|
t.Temperatures = []Sensor{}
|
|
}
|
|
if t.Fans == nil {
|
|
t.Fans = []Sensor{}
|
|
}
|
|
for i := range t.Temperatures {
|
|
if t.Hottest == nil || t.Temperatures[i].Value > t.Hottest.Value {
|
|
h := t.Temperatures[i]
|
|
t.Hottest = &h
|
|
}
|
|
}
|
|
return t
|
|
}
|
|
|
|
// PowerDraw is what the power-draw tool answers.
|
|
type PowerDraw struct {
|
|
Source Source `json:"source"`
|
|
BatteryW *float64 `json:"battery_w,omitempty"`
|
|
BatteryFlow string `json:"battery_flow,omitempty"`
|
|
CPUPackageW *float64 `json:"apu_package_w,omitempty"`
|
|
DGPU *DGPU `json:"dgpu,omitempty"`
|
|
Note string `json:"note"`
|
|
}
|
|
|
|
func (m *Machine) PowerDraw(ctx context.Context) PowerDraw {
|
|
p := PowerDraw{Source: PowerSource(m.Supplies()), DGPU: m.dgpu(ctx),
|
|
Note: "on battery, battery_w is what the whole machine draws; on mains it is only what the battery takes or gives"}
|
|
for _, b := range m.Batteries() {
|
|
if b.PowerW != nil {
|
|
w := *b.PowerW
|
|
p.BatteryW = &w
|
|
switch strings.ToLower(b.Status) {
|
|
case "discharging":
|
|
p.BatteryFlow = "discharging"
|
|
case "charging":
|
|
p.BatteryFlow = "charging"
|
|
default:
|
|
p.BatteryFlow = strings.ToLower(b.Status)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
// The integrated GPU's hwmon reports the whole APU's package power (PPT) on this model.
|
|
for _, s := range m.hwmon("power") {
|
|
if s.Chip == "amdgpu" && s.Label == "PPT" {
|
|
w := s.Value
|
|
p.CPUPackageW = &w
|
|
}
|
|
}
|
|
return p
|
|
}
|