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).
265 lines
8.6 KiB
Go
265 lines
8.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// The vendor daemons are reached through their own command-line clients, which speak to them on the
|
|
// system bus. Their bus policy admits the `users` and `wheel` groups, so none of this needs root.
|
|
|
|
// Profiles are the platform profiles asusd offers on this model, in its spelling.
|
|
var Profiles = []string{"Quiet", "Balanced", "Performance"}
|
|
|
|
// canonicalProfile accepts any case and answers asusd's spelling, or an error naming the choices.
|
|
func canonicalProfile(s string) (string, error) {
|
|
for _, p := range Profiles {
|
|
if strings.EqualFold(strings.TrimSpace(s), p) {
|
|
return p, nil
|
|
}
|
|
}
|
|
return "", fmt.Errorf("profile %q is not one of %s", s, strings.Join(Profiles, ", "))
|
|
}
|
|
|
|
// ProfileState is what asusd says about the platform profile.
|
|
type ProfileState struct {
|
|
Active string `json:"active"`
|
|
OnAC string `json:"on_ac,omitempty"`
|
|
Battery string `json:"on_battery,omitempty"`
|
|
Platform string `json:"platform_profile,omitempty"`
|
|
Choices string `json:"platform_profile_choices,omitempty"`
|
|
}
|
|
|
|
var (
|
|
activeProfile = regexp.MustCompile(`(?m)^Active profile:\s*(\S+)`)
|
|
acProfile = regexp.MustCompile(`(?m)^AC profile\s+(\S+)`)
|
|
batteryProfile = regexp.MustCompile(`(?m)^Battery profile\s+(\S+)`)
|
|
)
|
|
|
|
// ParseProfileGet reads `asusctl profile get`.
|
|
func ParseProfileGet(out string) (ProfileState, error) {
|
|
var p ProfileState
|
|
if m := activeProfile.FindStringSubmatch(out); m != nil {
|
|
p.Active = m[1]
|
|
} else {
|
|
return p, fmt.Errorf("asusctl profile get said no active profile: %q", strings.TrimSpace(out))
|
|
}
|
|
if m := acProfile.FindStringSubmatch(out); m != nil {
|
|
p.OnAC = m[1]
|
|
}
|
|
if m := batteryProfile.FindStringSubmatch(out); m != nil {
|
|
p.Battery = m[1]
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// Profile reads the platform profile from asusd and the kernel.
|
|
func (m *Machine) Profile(ctx context.Context) (ProfileState, error) {
|
|
out, err := m.Run(ctx, "asusctl", "profile", "get")
|
|
if err != nil {
|
|
return ProfileState{}, vendor("asusctl", err)
|
|
}
|
|
p, err := ParseProfileGet(out)
|
|
if err != nil {
|
|
return p, err
|
|
}
|
|
p.Platform = m.read("/sys/firmware/acpi/platform_profile")
|
|
p.Choices = m.read("/sys/firmware/acpi/platform_profile_choices")
|
|
return p, nil
|
|
}
|
|
|
|
// SetProfile has asusd switch the active profile.
|
|
func (m *Machine) SetProfile(ctx context.Context, profile string) error {
|
|
_, err := m.Run(ctx, "asusctl", "profile", "set", profile)
|
|
return vendor("asusctl", err)
|
|
}
|
|
|
|
var chargeLimit = regexp.MustCompile(`charge limit:\s*(\d+)\s*%`)
|
|
|
|
// ChargeLimit is the battery's charge limit as asusd reports it.
|
|
func (m *Machine) ChargeLimit(ctx context.Context) (int, error) {
|
|
out, err := m.Run(ctx, "asusctl", "battery", "info")
|
|
if err != nil {
|
|
return 0, vendor("asusctl", err)
|
|
}
|
|
g := chargeLimit.FindStringSubmatch(out)
|
|
if g == nil {
|
|
return 0, fmt.Errorf("asusctl battery info said no limit: %q", strings.TrimSpace(out))
|
|
}
|
|
n, _ := strconv.Atoi(g[1])
|
|
return n, nil
|
|
}
|
|
|
|
// Keyboard backlight levels in asusd's spelling, index = the kernel's brightness value.
|
|
var KeyboardLevels = []string{"off", "low", "med", "high"}
|
|
|
|
var ledLevel = regexp.MustCompile(`(?i)brightness:\s*(off|low|med|high)`)
|
|
|
|
// ParseLeds reads `asusctl leds get`.
|
|
func ParseLeds(out string) (string, error) {
|
|
g := ledLevel.FindStringSubmatch(out)
|
|
if g == nil {
|
|
return "", fmt.Errorf("asusctl leds get said no level: %q", strings.TrimSpace(out))
|
|
}
|
|
return strings.ToLower(g[1]), nil
|
|
}
|
|
|
|
// FanCurve is one fan's curve in one profile: eight points of temperature (°C) and duty (0-255).
|
|
type FanCurve struct {
|
|
Fan string `json:"fan"`
|
|
Enabled bool `json:"enabled"`
|
|
Temp []int `json:"temp_c"`
|
|
PWM []int `json:"pwm"`
|
|
}
|
|
|
|
var (
|
|
fanBlock = regexp.MustCompile(`(?s)fan:\s*(\w+),\s*pwm:\s*\(([^)]*)\),\s*temp:\s*\(([^)]*)\),\s*enabled:\s*(true|false)`)
|
|
)
|
|
|
|
// ParseFanCurves reads `asusctl fan-curve --mod-profile <p>`.
|
|
func ParseFanCurves(out string) []FanCurve {
|
|
var curves []FanCurve
|
|
for _, g := range fanBlock.FindAllStringSubmatch(out, -1) {
|
|
curves = append(curves, FanCurve{Fan: g[1], PWM: ints(g[2]), Temp: ints(g[3]), Enabled: g[4] == "true"})
|
|
}
|
|
return curves
|
|
}
|
|
|
|
func ints(list string) []int {
|
|
var out []int
|
|
for _, f := range strings.Split(list, ",") {
|
|
if n, err := strconv.Atoi(strings.TrimSpace(f)); err == nil {
|
|
out = append(out, n)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GPUState is what supergfxd says about the hybrid GPU.
|
|
type GPUState struct {
|
|
Mode string `json:"mode"`
|
|
Supported []string `json:"supported"`
|
|
Power string `json:"dgpu_power,omitempty"`
|
|
PendingAction string `json:"pending_action,omitempty"`
|
|
PendingMode string `json:"pending_mode,omitempty"`
|
|
Vendor string `json:"dgpu_vendor,omitempty"`
|
|
}
|
|
|
|
// ParseSupported reads `supergfxctl -s`: `[Integrated, Hybrid, AsusMuxDgpu]`.
|
|
func ParseSupported(out string) []string {
|
|
out = strings.Trim(strings.TrimSpace(out), "[]")
|
|
var modes []string
|
|
for _, f := range strings.Split(out, ",") {
|
|
if f = strings.TrimSpace(f); f != "" {
|
|
modes = append(modes, f)
|
|
}
|
|
}
|
|
return modes
|
|
}
|
|
|
|
// GPU reads supergfxd.
|
|
func (m *Machine) GPU(ctx context.Context) (GPUState, error) {
|
|
var g GPUState
|
|
mode, err := m.Run(ctx, "supergfxctl", "-g")
|
|
if err != nil {
|
|
return g, vendor("supergfxctl", err)
|
|
}
|
|
g.Mode = strings.TrimSpace(mode)
|
|
if s, err := m.Run(ctx, "supergfxctl", "-s"); err == nil {
|
|
g.Supported = ParseSupported(s)
|
|
}
|
|
if s, err := m.Run(ctx, "supergfxctl", "-S"); err == nil {
|
|
g.Power = strings.TrimSpace(s)
|
|
}
|
|
if s, err := m.Run(ctx, "supergfxctl", "-p"); err == nil {
|
|
g.PendingAction = strings.TrimSpace(s)
|
|
}
|
|
if s, err := m.Run(ctx, "supergfxctl", "-P"); err == nil {
|
|
g.PendingMode = strings.TrimSpace(s)
|
|
}
|
|
if s, err := m.Run(ctx, "supergfxctl", "-V"); err == nil {
|
|
g.Vendor = strings.TrimSpace(s)
|
|
}
|
|
return g, nil
|
|
}
|
|
|
|
// vendor names a vendor client that is not installed, rather than passing on "executable file not
|
|
// found". asusctl is in the distribution's repositories; supergfxctl is not, and the module keeps it as
|
|
// it was found until the mesh can build packages from the user repository (research 027, question 1).
|
|
func vendor(name string, err error) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
if notInstalled(err) {
|
|
switch name {
|
|
case "supergfxctl":
|
|
return fmt.Errorf("supergfxctl is not installed: it is not in the distribution's repositories, " +
|
|
"and this module keeps the copy it finds rather than install one (novox/hq research 027, question 1)")
|
|
default:
|
|
return fmt.Errorf("%s is not installed; the module's package resource installs it", name)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
// AsusdConfig is the few settings of asusd's own file that decide what this module's code does. The
|
|
// file is asusd's: it rewrites it whenever a setting changes, so the module reads it and never writes
|
|
// it.
|
|
type AsusdConfig struct {
|
|
ChargeLimit *int `json:"charge_control_end_threshold,omitempty"`
|
|
ProfileOnAC string `json:"platform_profile_on_ac,omitempty"`
|
|
ProfileOnBattery string `json:"platform_profile_on_battery,omitempty"`
|
|
ChangesProfileOnAC *bool `json:"change_platform_profile_on_ac,omitempty"`
|
|
ChangesProfileOnBatt *bool `json:"change_platform_profile_on_battery,omitempty"`
|
|
ACCommand string `json:"ac_command,omitempty"`
|
|
BatteryCommand string `json:"bat_command,omitempty"`
|
|
DisablesPowerdOnBatt *bool `json:"disable_nvidia_powerd_on_battery,omitempty"`
|
|
}
|
|
|
|
var ronField = regexp.MustCompile(`(?m)^\s{4}([a-z_]+):\s*(.*?),?\s*$`)
|
|
|
|
// ParseAsusdRon reads the top-level scalar fields of asusd.ron. RON is not a format the mesh
|
|
// speaks; these are one line each, and nothing nested is read.
|
|
func ParseAsusdRon(text string) AsusdConfig {
|
|
var c AsusdConfig
|
|
for _, g := range ronField.FindAllStringSubmatch(text, -1) {
|
|
key, value := g[1], strings.TrimSuffix(strings.TrimSpace(g[2]), ",")
|
|
unquoted := strings.Trim(value, `"`)
|
|
boolean := func() *bool { b := value == "true"; return &b }
|
|
switch key {
|
|
case "charge_control_end_threshold":
|
|
if n, err := strconv.Atoi(value); err == nil {
|
|
c.ChargeLimit = &n
|
|
}
|
|
case "platform_profile_on_ac":
|
|
c.ProfileOnAC = unquoted
|
|
case "platform_profile_on_battery":
|
|
c.ProfileOnBattery = unquoted
|
|
case "change_platform_profile_on_ac":
|
|
c.ChangesProfileOnAC = boolean()
|
|
case "change_platform_profile_on_battery":
|
|
c.ChangesProfileOnBatt = boolean()
|
|
case "ac_command":
|
|
c.ACCommand = unquoted
|
|
case "bat_command":
|
|
c.BatteryCommand = unquoted
|
|
case "disable_nvidia_powerd_on_battery":
|
|
c.DisablesPowerdOnBatt = boolean()
|
|
}
|
|
}
|
|
return c
|
|
}
|
|
|
|
// Asusd reads asusd's file; nil when it is not there.
|
|
func (m *Machine) Asusd() *AsusdConfig {
|
|
text := m.read("/etc/asusd/asusd.ron")
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
c := ParseAsusdRon(text)
|
|
return &c
|
|
}
|