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 }