Files
mesh-catalog/modules/asus-zephyrus-g14/cmd/zephyrus/asus.go
T
jochen 9c84cd9e6c Phase 3: asus-zephyrus-g14 and memory-pressure, with Go tools and long-running code
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).
2026-10-04 12:56:23 +02:00

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
}