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).
304 lines
10 KiB
Go
304 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// The module's long-running code (novox/hq ADR 0198): the profile switcher, launched with the tools
|
|
// by the node's runtime and running beside them in the same process.
|
|
//
|
|
// It replaces the predecessor's `auto-profile`, a user unit that woke every five seconds for ever —
|
|
// read the adapters, read /proc/stat, maybe call asusctl — on battery too, where its only possible
|
|
// answer was the one it had already given. Here the kernel's power-supply event is the trigger; the
|
|
// CPU is sampled only on mains, where the answer depends on it; and on battery the process sleeps
|
|
// until the adapter comes back.
|
|
//
|
|
// **It acts on a change of its decision, never to restore one.** A profile chosen by hand — the
|
|
// vendor's profile key, asusctl in a terminal, the profile tool — stays until the power source
|
|
// changes or the load crosses a line. The predecessor re-asserted its choice every five seconds and so
|
|
// made the profile key useless on battery.
|
|
|
|
// Emitter publishes an event as the module; nil when the process is not under the runtime.
|
|
type Emitter func(eventType string, body any) error
|
|
|
|
// Switcher is the switcher's state, shared with the tools that report it.
|
|
type Switcher struct {
|
|
m *Machine
|
|
now func() time.Time
|
|
emit Emitter
|
|
|
|
mu sync.Mutex
|
|
policy Policy
|
|
source *Source
|
|
decision *Decision
|
|
applied string
|
|
appliedAt time.Time
|
|
lastError string
|
|
holdUntil time.Time
|
|
holdOf string
|
|
watching string
|
|
cpuPrev *CPUTimes
|
|
disabled string
|
|
asserted []string
|
|
}
|
|
|
|
func NewSwitcher(m *Machine, emit Emitter) *Switcher {
|
|
return &Switcher{m: m, now: time.Now, emit: emit}
|
|
}
|
|
|
|
// Model is the machine's product family as its firmware reports it.
|
|
func (m *Machine) Model() string { return m.read("/sys/class/dmi/id/product_family") }
|
|
|
|
// ModelFamily is the family this module is written for.
|
|
const ModelFamily = "ROG Zephyrus G14"
|
|
|
|
// ThisModel says whether the machine is the model this module is written for.
|
|
func (m *Machine) ThisModel() bool { return strings.EqualFold(m.Model(), ModelFamily) }
|
|
|
|
// sampleCPU reads /proc/stat and answers the busy percentage since the previous reading.
|
|
func (s *Switcher) sampleCPU() (float64, bool) {
|
|
t, err := ParseProcStat(s.m.read("/proc/stat"))
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
prev := s.cpuPrev
|
|
s.cpuPrev = &t
|
|
if prev == nil {
|
|
return 0, false
|
|
}
|
|
return Busy(*prev, t)
|
|
}
|
|
|
|
// Evaluate reads the power source, takes a CPU sample when asked and on mains, decides, and applies
|
|
// the decision when it changed. It is the whole of one wake-up and what the tests drive.
|
|
func (s *Switcher) Evaluate(ctx context.Context, sample bool) {
|
|
if body := s.evaluate(ctx, sample); body != nil && s.emit != nil {
|
|
// Outside the lock: publishing waits for the bus, and the tools that report the switcher
|
|
// must not wait with it.
|
|
if err := s.emit("profile.switched", body); err != nil {
|
|
fmt.Fprintf(os.Stderr, "profile.switched not published: %v\n", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// evaluate is Evaluate under the lock; it answers the event to publish when it switched.
|
|
func (s *Switcher) evaluate(ctx context.Context, sample bool) map[string]any {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.disabled != "" {
|
|
return nil
|
|
}
|
|
src := PowerSource(s.m.Supplies())
|
|
now := s.now()
|
|
first := s.source == nil
|
|
if first || s.source.OnAC != src.OnAC {
|
|
// A new power source: what was learnt about load on the other one says nothing here, and a
|
|
// hold was for the source it was asked on.
|
|
s.policy.Reset()
|
|
s.cpuPrev = nil
|
|
s.holdUntil = time.Time{}
|
|
s.holdOf = ""
|
|
s.sampleCPU() // the first reading on this source, so the next sample is a difference
|
|
} else if sample && src.OnAC {
|
|
if busy, ok := s.sampleCPU(); ok {
|
|
s.policy.Observe(busy, now)
|
|
}
|
|
}
|
|
s.source = &src
|
|
d := s.policy.Decide(src)
|
|
s.decision = &d
|
|
// **Starting is not a reason to switch.** The runtime starts this process on every push that
|
|
// changes a bundle; at boot and at every change of power source asusd has already applied its own
|
|
// profile for the source, which AssertVendorSettings made the policy's. So the first decision is
|
|
// taken as applied, and a profile someone chose by hand survives a push.
|
|
if first {
|
|
s.applied = d.Profile
|
|
return nil
|
|
}
|
|
// Compared with what the switcher itself last applied, never with the profile in force: a profile
|
|
// someone chose by hand is not a reason to act, a new decision is.
|
|
if d.Profile == s.applied || now.Before(s.holdUntil) {
|
|
return nil
|
|
}
|
|
from := s.applied
|
|
if err := s.m.SetProfile(ctx, d.Profile); err != nil {
|
|
s.lastError = err.Error() // and tried again at the next wake-up, since applied did not move
|
|
return nil
|
|
}
|
|
s.lastError = ""
|
|
s.applied, s.appliedAt = d.Profile, now
|
|
body := map[string]any{"profile": d.Profile, "reason": d.Reason, "source": src.Source}
|
|
if from != "" {
|
|
body["from"] = from
|
|
}
|
|
return body
|
|
}
|
|
|
|
// Hold keeps a profile chosen through the tool for a while: the switcher does not move it until the
|
|
// hold ends or the power source changes.
|
|
func (s *Switcher) Hold(profile string, d time.Duration) time.Time {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if d <= 0 {
|
|
s.holdUntil, s.holdOf = time.Time{}, ""
|
|
return time.Time{}
|
|
}
|
|
s.holdUntil, s.holdOf = s.now().Add(d), profile
|
|
return s.holdUntil
|
|
}
|
|
|
|
// Run is the switcher's life: assert asusd's settings once, then wake on each power-supply event, on
|
|
// each CPU sample while on mains, and at SafetyRecheck otherwise.
|
|
func (s *Switcher) Run(ctx context.Context) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
s.mu.Lock()
|
|
s.disabled = fmt.Sprintf("the switcher stopped on a fault: %v", r)
|
|
s.mu.Unlock()
|
|
fmt.Fprintln(os.Stderr, s.disabled)
|
|
}
|
|
}()
|
|
if !s.m.ThisModel() {
|
|
s.mu.Lock()
|
|
s.disabled = fmt.Sprintf("this machine reports %q, not %q: the switcher does not act on another model",
|
|
s.m.Model(), ModelFamily)
|
|
s.mu.Unlock()
|
|
fmt.Fprintln(os.Stderr, s.disabled)
|
|
return
|
|
}
|
|
s.AssertVendorSettings(ctx)
|
|
|
|
events, err := listenPowerSupply(ctx)
|
|
s.mu.Lock()
|
|
if err != nil {
|
|
s.watching = "polling every " + SampleEvery.String() + ": " + err.Error()
|
|
} else {
|
|
s.watching = "the kernel's power-supply events"
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
s.Evaluate(ctx, false)
|
|
timer := time.NewTimer(s.interval(err != nil))
|
|
defer timer.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case _, open := <-events:
|
|
if !open {
|
|
events = nil
|
|
s.mu.Lock()
|
|
s.watching = "polling every " + SampleEvery.String() + ": the uevent socket closed"
|
|
s.mu.Unlock()
|
|
err = fmt.Errorf("closed")
|
|
continue
|
|
}
|
|
// Settle: an adapter change arrives as several events within a moment.
|
|
time.Sleep(time.Second)
|
|
s.Evaluate(ctx, false)
|
|
case <-timer.C:
|
|
s.Evaluate(ctx, true)
|
|
timer.Reset(s.interval(err != nil))
|
|
}
|
|
}
|
|
}
|
|
|
|
// interval is how long to sleep: a CPU sample's period on mains (or with no events to wake on), the
|
|
// safety recheck on battery.
|
|
func (s *Switcher) interval(polling bool) time.Duration {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if polling || s.source == nil || s.source.OnAC {
|
|
return SampleEvery
|
|
}
|
|
return SafetyRecheck
|
|
}
|
|
|
|
// AssertVendorSettings puts asusd's own settings where the module wants them, once at start: the
|
|
// battery charge limit, and the profiles asusd itself switches to on mains and on battery, so that the
|
|
// vendor daemon's own switching and this module's never disagree. Each is read first and set only if
|
|
// it differs. A value changed later with a tool stands until the next start.
|
|
func (s *Switcher) AssertVendorSettings(ctx context.Context) []string {
|
|
var said []string
|
|
if limit, err := s.m.ChargeLimit(ctx); err != nil {
|
|
said = append(said, "charge limit not read: "+err.Error())
|
|
} else if limit != ChargeLimitPercent {
|
|
if _, err := s.m.Run(ctx, "asusctl", "battery", "limit", strconv.Itoa(ChargeLimitPercent)); err != nil {
|
|
said = append(said, "charge limit not set: "+vendor("asusctl", err).Error())
|
|
} else {
|
|
said = append(said, fmt.Sprintf("charge limit %d%% → %d%%", limit, ChargeLimitPercent))
|
|
}
|
|
} else {
|
|
said = append(said, fmt.Sprintf("charge limit already %d%%", ChargeLimitPercent))
|
|
}
|
|
p, err := s.m.Profile(ctx)
|
|
if err != nil {
|
|
said = append(said, "asusd's profiles not read: "+err.Error())
|
|
} else {
|
|
for _, want := range []struct{ flag, have, want, what string }{
|
|
{"-a", p.OnAC, ProfileOnAC, "on mains"},
|
|
{"-b", p.Battery, ProfileOnBattery, "on battery"},
|
|
} {
|
|
if want.have == "" || strings.EqualFold(want.have, want.want) {
|
|
continue
|
|
}
|
|
if _, err := s.m.Run(ctx, "asusctl", "profile", "set", want.flag, want.want); err != nil {
|
|
said = append(said, "asusd's profile "+want.what+" not set: "+err.Error())
|
|
} else {
|
|
said = append(said, fmt.Sprintf("asusd's profile %s %s → %s", want.what, want.have, want.want))
|
|
}
|
|
}
|
|
}
|
|
s.mu.Lock()
|
|
s.asserted = said
|
|
s.mu.Unlock()
|
|
for _, line := range said {
|
|
fmt.Fprintln(os.Stderr, line)
|
|
}
|
|
return said
|
|
}
|
|
|
|
// SwitcherReport is the switcher's state as the profile-policy tool shows it.
|
|
type SwitcherReport struct {
|
|
Running bool `json:"running"`
|
|
Disabled string `json:"disabled,omitempty"`
|
|
Watching string `json:"woken_by,omitempty"`
|
|
Source *Source `json:"source,omitempty"`
|
|
Decision *Decision `json:"decision,omitempty"`
|
|
Load Policy `json:"load"`
|
|
LastApplied string `json:"last_applied,omitempty"`
|
|
LastAppliedAt *time.Time `json:"last_applied_at,omitempty"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
HeldUntil *time.Time `json:"held_until,omitempty"`
|
|
Held string `json:"held_profile,omitempty"`
|
|
AssertedAtStart []string `json:"asserted_at_start,omitempty"`
|
|
}
|
|
|
|
func (s *Switcher) Report() SwitcherReport {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
r := SwitcherReport{
|
|
Running: s.watching != "" && s.disabled == "", Disabled: s.disabled, Watching: s.watching,
|
|
Source: s.source, Decision: s.decision, Load: s.policy, LastApplied: s.applied,
|
|
LastAppliedAt: when(s.appliedAt), LastError: s.lastError, AssertedAtStart: s.asserted,
|
|
}
|
|
if s.now().Before(s.holdUntil) {
|
|
r.HeldUntil, r.Held = when(s.holdUntil), s.holdOf
|
|
}
|
|
return r
|
|
}
|
|
|
|
// when is a time for a report: absent rather than the zero time.
|
|
func when(t time.Time) *time.Time {
|
|
if t.IsZero() {
|
|
return nil
|
|
}
|
|
return &t
|
|
}
|