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).
309 lines
11 KiB
Go
309 lines
11 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
)
|
|
|
|
// Tools is the module's tools, over one machine and its switcher.
|
|
func Tools(m *Machine, sw *Switcher) []stdio.Tool {
|
|
ctx := context.Background
|
|
return []stdio.Tool{
|
|
{
|
|
Name: "zephyrus_brightness",
|
|
Description: "Read or set the internal panel's and the keyboard's backlight. With no argument, reads both. " +
|
|
"panel is a percentage (40) or a step (+5, -10), never below 1 %; keyboard is off, low, med, high, 0-3, + or -.",
|
|
Input: map[string]any{
|
|
"panel": map[string]any{"type": "string", "description": "percentage or step, e.g. 40, +5, -10"},
|
|
"keyboard": map[string]any{"type": "string", "description": "off, low, med, high, 0-3, + or -"},
|
|
},
|
|
Run: func(args map[string]any) (any, error) {
|
|
out := map[string]any{}
|
|
if p := str(args, "panel"); p != "" {
|
|
got, err := m.SetPanel(ctx(), p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out["panel"] = got
|
|
} else if got, err := m.PanelBrightness(); err == nil {
|
|
out["panel"] = got
|
|
} else {
|
|
out["panel_error"] = err.Error()
|
|
}
|
|
if k := str(args, "keyboard"); k != "" {
|
|
got, err := m.SetKeyboard(ctx(), k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out["keyboard"] = got
|
|
} else if got, err := m.KeyboardBrightness(); err == nil {
|
|
out["keyboard"] = got
|
|
} else {
|
|
out["keyboard_error"] = err.Error()
|
|
}
|
|
return out, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "zephyrus_battery",
|
|
Description: "The battery: charge, energy, health (full against design), cycles, the charge limit, the power it gives or takes, and time left when discharging.",
|
|
Run: func(map[string]any) (any, error) {
|
|
return map[string]any{"source": PowerSource(m.Supplies()), "batteries": orEmpty(m.Batteries())}, nil
|
|
},
|
|
},
|
|
{
|
|
Name: "zephyrus_charge_limit",
|
|
Description: fmt.Sprintf("Read or set the battery charge limit through asusd. limit is 20-100; oneshot charges to full once "+
|
|
"and goes back to the limit. The module asserts %d %% again when its process next starts.", ChargeLimitPercent),
|
|
Input: map[string]any{
|
|
"limit": map[string]any{"type": "integer", "description": "20-100"},
|
|
"oneshot": map[string]any{"type": "boolean", "description": "charge to full once, keeping the limit"},
|
|
},
|
|
Run: func(args map[string]any) (any, error) { return ChargeLimitTool(ctx(), m, args) },
|
|
},
|
|
{
|
|
Name: "zephyrus_gpu_mode",
|
|
Description: "Read or set the hybrid GPU's mode through supergfxd: Integrated, Hybrid or AsusMuxDgpu as the machine supports. " +
|
|
"Answers the mode, the discrete GPU's power state, any pending mode and the action it waits for (a logout, a reboot), " +
|
|
"and whether asusd will switch it again on the next change of power source.",
|
|
Input: map[string]any{
|
|
"mode": map[string]any{"type": "string", "description": "a supported mode, e.g. Integrated or Hybrid"},
|
|
},
|
|
Run: func(args map[string]any) (any, error) { return GPUModeTool(ctx(), m, args) },
|
|
},
|
|
{
|
|
Name: "zephyrus_profile",
|
|
Description: "Read or set the platform profile (Quiet, Balanced, Performance) through asusd. A profile set here is held " +
|
|
fmt.Sprintf("for hold_minutes (default %d, 0 for none) before the module's switcher may move it; a change of power source ends the hold.", int(DefaultHold.Minutes())),
|
|
Input: map[string]any{
|
|
"profile": map[string]any{"type": "string", "enum": Profiles},
|
|
"hold_minutes": map[string]any{"type": "integer", "description": "how long the switcher leaves it (default 60, at most 1440)"},
|
|
},
|
|
Run: func(args map[string]any) (any, error) { return ProfileTool(ctx(), m, sw, args) },
|
|
},
|
|
{
|
|
Name: "zephyrus_thermals",
|
|
Description: "Every temperature and fan the hardware reports (°C, RPM), the hottest, the platform profile, and the discrete GPU's temperature when it is awake (it is not woken to be read).",
|
|
Run: func(map[string]any) (any, error) { return m.Thermals(ctx()), nil },
|
|
},
|
|
{
|
|
Name: "zephyrus_power_draw",
|
|
Description: "What the machine draws: the battery's flow in watts, the APU's package power, the discrete GPU's draw when awake, and the power source with the reason it was decided.",
|
|
Run: func(map[string]any) (any, error) { return m.PowerDraw(ctx()), nil },
|
|
},
|
|
{
|
|
Name: "zephyrus_profile_policy",
|
|
Description: "What the module's profile switcher would choose now and why: the power source, recent CPU load against the thresholds, " +
|
|
"the decision, the profile in force, any hold, what woke it, and what it asserted in asusd at start.",
|
|
Run: func(map[string]any) (any, error) { return PolicyTool(ctx(), m, sw), nil },
|
|
},
|
|
{
|
|
Name: "zephyrus_fan_curves",
|
|
Description: "The fan curves asusd holds for each profile (or one profile): per fan, eight points of temperature and duty.",
|
|
Input: map[string]any{
|
|
"profile": map[string]any{"type": "string", "enum": Profiles},
|
|
},
|
|
Run: func(args map[string]any) (any, error) { return FanCurvesTool(ctx(), m, args) },
|
|
},
|
|
{
|
|
Name: "zephyrus_check",
|
|
Description: "Check what this module expects of the machine: the model, the vendor packages and daemons, the NVIDIA options in force, " +
|
|
"suspend and resume, the charge limit, one authority each over the profile and the GPU mode, and the predecessor's leftovers. Says what it did not check.",
|
|
Run: func(map[string]any) (any, error) { return m.Check(ctx(), sw), nil },
|
|
},
|
|
}
|
|
}
|
|
|
|
// ChargeLimitTool reads or sets the limit.
|
|
func ChargeLimitTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
|
|
out := map[string]any{"module_limit_percent": ChargeLimitPercent}
|
|
if v, given := args["limit"]; given && v != nil {
|
|
n, err := whole(v, "limit")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if n < 20 || n > 100 {
|
|
return nil, fmt.Errorf("limit %d is outside 20-100", n)
|
|
}
|
|
if _, err := m.Run(ctx, "asusctl", "battery", "limit", strconv.Itoa(n)); err != nil {
|
|
return nil, vendor("asusctl", err)
|
|
}
|
|
out["set"] = n
|
|
}
|
|
if b, _ := args["oneshot"].(bool); b {
|
|
if _, err := m.Run(ctx, "asusctl", "battery", "oneshot"); err != nil {
|
|
return nil, vendor("asusctl", err)
|
|
}
|
|
out["oneshot"] = "charging to full once; the limit returns after"
|
|
}
|
|
if n, err := m.ChargeLimit(ctx); err == nil {
|
|
out["asusd_limit_percent"] = n
|
|
} else {
|
|
out["asusd_error"] = err.Error()
|
|
}
|
|
for _, b := range m.Batteries() {
|
|
if b.LimitPercent != nil {
|
|
out["kernel_limit_percent"] = *b.LimitPercent
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// GPUModeTool reads or sets the GPU mode.
|
|
func GPUModeTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
|
|
g, err := m.GPU(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := map[string]any{}
|
|
if want := str(args, "mode"); want != "" {
|
|
mode := ""
|
|
for _, s := range g.Supported {
|
|
if strings.EqualFold(s, want) {
|
|
mode = s
|
|
}
|
|
}
|
|
if mode == "" {
|
|
return nil, fmt.Errorf("mode %q is not one this machine supports (%s)", want, strings.Join(g.Supported, ", "))
|
|
}
|
|
said, err := m.Run(ctx, "supergfxctl", "-m", mode)
|
|
if err != nil {
|
|
return nil, vendor("supergfxctl", err)
|
|
}
|
|
out["requested"] = mode
|
|
if s := strings.TrimSpace(said); s != "" {
|
|
out["supergfxctl_said"] = s
|
|
}
|
|
if g, err = m.GPU(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
out["gpu"] = g
|
|
if c := m.Asusd(); c != nil && (c.ACCommand != "" || c.BatteryCommand != "") {
|
|
out["asusd_switches_it"] = map[string]string{"on_ac": c.ACCommand, "on_battery": c.BatteryCommand,
|
|
"note": "asusd runs these on every change of power source, so a mode set here lasts until the next one"}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// ProfileTool reads or sets the profile.
|
|
func ProfileTool(ctx context.Context, m *Machine, sw *Switcher, args map[string]any) (any, error) {
|
|
out := map[string]any{}
|
|
if want := str(args, "profile"); want != "" {
|
|
p, err := canonicalProfile(want)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hold := DefaultHold
|
|
if v, given := args["hold_minutes"]; given && v != nil {
|
|
n, err := whole(v, "hold_minutes")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if n < 0 {
|
|
return nil, fmt.Errorf("hold_minutes must not be negative")
|
|
}
|
|
hold = time.Duration(min(n, 1440)) * time.Minute
|
|
}
|
|
if err := m.SetProfile(ctx, p); err != nil {
|
|
return nil, err
|
|
}
|
|
out["set"] = p
|
|
if sw != nil {
|
|
if until := sw.Hold(p, hold); !until.IsZero() {
|
|
out["held_until"] = until
|
|
}
|
|
}
|
|
}
|
|
state, err := m.Profile(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out["profile"] = state
|
|
return out, nil
|
|
}
|
|
|
|
// PolicyTool reports the switcher and, independently of it, what the policy says now.
|
|
func PolicyTool(ctx context.Context, m *Machine, sw *Switcher) any {
|
|
out := map[string]any{
|
|
"thresholds": map[string]any{
|
|
"on_battery": ProfileOnBattery, "on_ac": ProfileOnAC, "under_load": ProfileUnderLoad,
|
|
"cpu_high_percent": CPUHighPercent, "cpu_low_percent": CPULowPercent,
|
|
"sample_every": SampleEvery.String(), "sustain_samples": SustainSamples, "relax_samples": RelaxSamples,
|
|
"set_by": "constants until settings exist (novox/hq issue 168)",
|
|
},
|
|
}
|
|
if sw != nil {
|
|
out["switcher"] = sw.Report()
|
|
} else {
|
|
var p Policy
|
|
out["decision_now"] = p.Decide(PowerSource(m.Supplies()))
|
|
}
|
|
if state, err := m.Profile(ctx); err == nil {
|
|
out["in_force"] = state
|
|
} else {
|
|
out["in_force_error"] = err.Error()
|
|
}
|
|
if pid, ok := m.predecessorProcess("auto-profile"); ok {
|
|
out["second_switcher"] = fmt.Sprintf("the predecessor's auto-profile still runs (pid %d) and overrides this every five seconds", pid)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// FanCurvesTool reads asusd's fan curves.
|
|
func FanCurvesTool(ctx context.Context, m *Machine, args map[string]any) (any, error) {
|
|
profiles := Profiles
|
|
if want := str(args, "profile"); want != "" {
|
|
p, err := canonicalProfile(want)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
profiles = []string{p}
|
|
}
|
|
out := map[string]any{}
|
|
for _, p := range profiles {
|
|
said, err := m.Run(ctx, "asusctl", "fan-curve", "--mod-profile", strings.ToLower(p))
|
|
if err != nil {
|
|
return nil, vendor("asusctl", err)
|
|
}
|
|
out[p] = orEmpty(ParseFanCurves(said))
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func str(args map[string]any, key string) string {
|
|
s, _ := args[key].(string)
|
|
return strings.TrimSpace(s)
|
|
}
|
|
|
|
// whole is an integer argument given as a JSON number or a numeric string.
|
|
func whole(v any, key string) (int, error) {
|
|
switch n := v.(type) {
|
|
case float64:
|
|
if n != math.Trunc(n) {
|
|
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
|
|
}
|
|
return int(n), nil
|
|
case string:
|
|
i, err := strconv.Atoi(strings.TrimSpace(n))
|
|
if err != nil {
|
|
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
|
|
}
|
|
return i, nil
|
|
}
|
|
return 0, fmt.Errorf("%s must be a whole number", key)
|
|
}
|
|
|
|
func orEmpty[T any](s []T) []T {
|
|
if s == nil {
|
|
return []T{}
|
|
}
|
|
return s
|
|
}
|