diff --git a/modules/asus-zephyrus-g14/README.md b/modules/asus-zephyrus-g14/README.md new file mode 100644 index 0000000..e3e17e0 --- /dev/null +++ b/modules/asus-zephyrus-g14/README.md @@ -0,0 +1,257 @@ +# asus-zephyrus-g14 + +The hardware module for the **ASUS ROG Zephyrus G14** laptop: its vendor daemon and platform +profiles, the hybrid GPU's mode and driver options, suspend, the lid and power key, low battery, +the backlights, the vendor keys and the touchpad (novox/hq research 027/03 *Power management on +the laptop*, research 026/05, to-be 42 phase 3). + +## Why this name + +A module is named after the hardware model, never the node (novox/hq ADR 0112; research 026/03: +no flavors, no machine names). `asus-zephyrus-g14` is the model family exactly as the firmware +reports it (`/sys/class/dmi/id/product_family` = `ROG Zephyrus G14`). The module's code checks that +value and its switcher does nothing on any other model, and `zephyrus_check` reports it. + +A wider name such as `asus-rog-laptop` would promise what this module cannot keep. Its contents +belong to this family: the vendor-key scan codes, the eDP panel beside an NVIDIA dGPU, and the NVIDIA +D3 workaround. A second G14 is assigned the same module. Another ROG model gets its own. + +Written against the GA403 (2024, Ryzen 8945HS, RTX 4070 Laptop, hybrid). Older G14 years have the same +daemons and probably the same keys. Their GPU options are unverified. + +## What it owns + +| | what | how | +|---|---|---| +| package | `asusctl` (asusd + client) | the distribution's package (`extra`). The machine was found with a local build of 6.4.0. The host only asserts *present*, so the switch to 6.5.0 from `extra` happens at the next `pacman -Syu` (or `pacman -S asusctl`). `zephyrus_check` flags a local build | +| package | `upower`, `playerctl`, `xorg-xinput` | what the low-battery drop-in, the media keys and the touchpad key use | +| service | `asusd` running (static unit: no boot state to declare), `supergfxd` running and enabled | | +| archive | `/usr/local/lib/asus-zephyrus-g14/bin/` | the module's scripts, from `files/bin` (below) | +| file | `/etc/modprobe.d/g14-nvidia-power.conf` | `NVreg_DynamicPowerManagement=0x00` (runtime D3 off: the ACPI D-Notifier hang) and `NVreg_PreserveVideoMemoryAllocations=1`. The path is adopted (ADR 0182) | +| file | `/etc/modprobe.d/video-brightness-switch.conf` | `video.brightness_switch_enabled=0`, so the ACPI video driver does not also move a backlight on the keys. The file was on the machine and owned by nothing | +| file ×3 | `systemd-{suspend,hibernate,suspend-then-hibernate}.service.d/asus-zephyrus-g14-nvidia.conf` | `Wants=` the matching `nvidia-*` sleep units and `nvidia-resume` (see *suspend units* below) | +| file | `nvidia-powerd.service.d/asus-zephyrus-g14.conf` | `ConditionKernelCommandLine=zephyrus.nvidia-powerd`: Dynamic Boost runs only when the operator opts in at boot | +| file | `/etc/systemd/logind.conf.d/power.conf` | the power key and the lid suspend, on battery, on mains and docked. `systemd-logind` is reloaded, never restarted | +| file | `/etc/udev/rules.d/90-backlight.rules` | backlights writable by the `video` group. `systemd-udevd` is reloaded | +| file | `triggerhappy.service.d/asus-zephyrus-g14.conf` | `thd … --user ${machine:account}`: the triggers run as the operator's account (below) | +| file | `/etc/triggerhappy/triggers.d/asus-g14.conf` | the vendor keys: media (`KEY_PROG1/3/4`), panel brightness, touchpad (`KEY_F21`). The path is adopted, because two trigger files would fire every key twice | +| file | `/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf` | low battery at 15/10/7 %; at 7 % **suspend**, not power off. A drop-in over the package's own file | +| file | `/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf` | tap to click, natural scrolling, acceleration 0.15, as an X input class | + +**What it does not own, on purpose:** + +- `/etc/asusd/*.ron` belong to asusd, which rewrites them whenever a setting changes. RON is not a + format the host writes into (ADR 0102 speaks JSON and marked blocks). Owning the file whole would + repeat the predecessor's freeze: the measured file already differs from the one the predecessor + shipped. The settings the module needs are set through asusd, by its code (below). +- `/etc/supergfxd.conf` and `/etc/modprobe.d/supergfxd.conf` belong to supergfxd, which writes both. +- The swap file, its unit and the swap partition are the machine's swap layout (research 027, + question 3). They are not this module's, nor `memory-pressure`'s. +- The i3 fragments (`~/.config/i3/config.d/10-asus.conf`, `20-g14.conf`) and the keyboard-backlight + notifier they start belong to phase 2 (the `i3` module). + +## Software outside the distribution (ADR 0205, research 027 question 1) + +`supergfxctl` (5.2.7, from the asus-linux repository, which is no longer configured) and +`triggerhappy` (AUR) are **kept as found, and depended on**. The module declares no package for +either, because the host installs from the official repositories only. It declares their services +(`supergfxd` running, `triggerhappy` running), so on a machine without them the host refuses the +service by name: *does not exist on this machine*. The refusal is loud, never a silent pass. +`zephyrus_check` names both as foreign. + +This module does not choose between the options of research 027 question 1. Under the starting +position (P2: the build machine builds AUR packages into a repository the mesh serves), both become +`package` resources here, and a fresh G14 installs them. **Until P2 exists, a fresh G14 is blocked +on installing these two by hand.** ADR 0205's vendored archive (P1) does not fit: supergfxctl is a +daemon with a system-bus policy and udev rules, and triggerhappy is C. + +A later option for the keys: the module's own Go code could read the vendor keys from evdev, which +the operator's account may do through the `input` group. That would retire triggerhappy entirely. +It is not done here, because it would put the keys behind the node's runtime, and the runtime +restarts a bundle that dies only on its next call (below). + +## The long-running code: the profile switcher (ADR 0198) + +The module's Go bundle serves the tools and runs the platform-profile switcher in the same process. +The node's runtime launches the bundle at the runtime's start. It replaces the predecessor's +`auto-profile`, a user unit that woke every five seconds, on battery too. + +- **Policy** (constants until settings exist, issue 168): battery → `Quiet`; mains → `Balanced`; + mains with the CPU at or above 50 % for 3 samples of 10 s → `Performance`, back to `Balanced` after + 3 samples at or below 20 %. Between the lines nothing moves (hysteresis). iowait counts as idle. +- **Woken by events, not a poll.** The kernel's power-supply uevents (netlink, group 1) wake the + switcher. Any account may listen on that group, and it needs no daemon, bus client or dependency; + upower re-announces the same changes but would need a D-Bus client in the bundle. The CPU is + sampled only on mains, every 10 s, because only there does the answer depend on it. On battery, + a safety re-read every 5 minutes covers an event lost across a suspend. If the uevent socket + cannot be opened, the switcher polls every 10 s and says so in `zephyrus_profile_policy`. +- **The battery decides the source.** A battery that is *discharging* means battery, whatever any + adapter says. The predecessor took any `online` file reading 1 as mains, and on this model the USB-C + ports report `online`. Batteries of `scope=Device` (a mouse, a headset) are ignored. +- **It acts on a change of its decision, never to restore one.** A profile someone chose by hand (the + profile key, asusctl, `zephyrus_profile`) stays until the power source changes or the load crosses + a line. The predecessor re-asserted its choice every five seconds, which made the profile key + useless. **Starting is not a decision**: the runtime restarts the bundle on every push that changes + one, and a push must not reset the operator's profile. +- **A hold.** `zephyrus_profile` holds the profile it sets for 60 min (`hold_minutes`). A change of + power source ends the hold. +- **One assertion at start:** through asusctl, the charge limit (80 %) and asusd's own on-mains and + on-battery profiles (`Balanced`, `Quiet`), each read first and set only if it differs. asusd's own + switching on a change of power source then agrees with the switcher's. A limit set later with + `zephyrus_charge_limit` stands until the bundle next starts. For a one-off full charge, use its + `oneshot`. +- **Events:** `profile.switched` (`profile`, `from`, `reason`, `source`), published through the + runtime. + +No root is involved. asusd's and supergfxd's bus policies admit the `users` and `wheel` groups, and the +runtime runs as the operator's account. The one write that may escalate is the panel's backlight, +when the udev rule has not run yet. It uses `sudo -n` and never prompts. Every command is bounded at +20 s. + +**Known limit.** The runtime restarts a launched bundle that exits *on its next tool call*, not at +once (mesh-tools `launch.ts`), so a crashed switcher stays down until a tool is called. ADR 0198 §1 +says *started again when it exits*. The switcher recovers from a panic and reports it in +`zephyrus_profile_policy` and `zephyrus_check`, but a crash of the process is the runtime's to restart. + +## The vendor keys and the scripts + +triggerhappy opens the input devices as root, then **drops to the operator's account with its groups** +(`initgroups`: `input`, `video`). The packaged unit already drops to `nobody`, and the module's drop-in +names the account instead. The predecessor replaced the packaged unit with one that ran every trigger +as root, then `su`-ed to a named person with a hard-coded uid and display, and sourced a file of +secrets on the way (research 027 question 2). Now: + +- `zephyrus-session CMD…`: runs a command in the account's graphical session. It sets the account's + own bus (`/run/user//bus`) and finds the display from logind, or from a process of the account + that has one. Nothing is sourced. +- `zephyrus-backlight + | - | N`: the panel in 5 % steps, never below 1 %. **The panel is the + backlight under the eDP connector**, because this model also registers `nvidia_0`, which moves + nothing. The predecessor named `amdgpu_bl1` literally. +- `zephyrus-notify ID TEXT`: one replacing notification, through `busctl` (the service manager's + client, so no libnotify). +- `zephyrus-touchpad reset | toggle`: bound to the touchpad key (`KEY_F21`). + +**Media keys** go to MPRIS through `playerctl`. The predecessor's Plex fallback needed a Plex token +from the secrets file and is dropped until a module can be handed a secret (research 027 question 2). + +## The touchpad: an input class instead of a sleep hook + +The predecessor re-ran `xinput` from `/etc/systemd/system-sleep/` after every resume, as a named person +on a guessed display, because settings made with `xinput` are lost when the device initialises again. +An X input class is applied by X **every time the device appears**: at login, on hotplug and after a +resume. So the cause is fixed, and the hook is gone. The class matches any touchpad on the machine, +which is the model's, so it holds across G14 years whose touchpads differ. It takes effect at the next +X start. `zephyrus-touchpad reset` stays as the manual form. + +## Suspend units without enabling them + +`nvidia-suspend`, `-hibernate`, `-suspend-then-hibernate` and `-resume` are enabled with links in the +sleep services' `.wants` directories. The mesh makes no links (ADR 0012). The host's service shape +cannot declare them either: it may only say *running* or *stopped*, and *running* on a one-shot that +last failed would start `nvidia-sleep.sh suspend` with the machine awake. So the module asks for them +from the other side: a drop-in on each sleep service that `Wants=` them. The units' own +`Before=`/`After=` order them. The found links stay and are harmless. + +`suspend-then-hibernate` now also gets `nvidia-suspend-then-hibernate`, which the machine lacked. + +The drop-ins take effect at the service manager's next `daemon-reload`. In the same apply, the restart +of `triggerhappy` (whose drop-in changes) performs one. + +## Tools + +| tool | r/a | what | +|---|---|---| +| `zephyrus_brightness` | r/a | panel (percent or ±step, floor 1 %) and keyboard (off/low/med/high, 0-3, ±) through asusd | +| `zephyrus_battery` | r | charge, energy in Wh, health (full ÷ design), cycles (the firmware reports 0, and this is said), limit, watts, hours left | +| `zephyrus_charge_limit` | r/a | 20-100 through asusd; `oneshot` | +| `zephyrus_gpu_mode` | r/a | mode, supported modes, dGPU power, the pending mode and action; says that asusd switches the mode on every change of power source | +| `zephyrus_profile` | r/a | active, on-mains and on-battery profile, kernel platform profile; set with a hold | +| `zephyrus_thermals` | r | every hwmon temperature and fan, the hottest, the dGPU's temperature **only when it is awake** (nvidia-smi wakes a suspended GPU) | +| `zephyrus_power_draw` | r | battery flow, APU package power (PPT), dGPU draw when awake, power source and why | +| `zephyrus_profile_policy` | r | what the switcher would choose now and why: source, recent load against the thresholds, decision, hold, last switch, what woke it, what it asserted at start, and whether the predecessor's switcher still runs | +| `zephyrus_fan_curves` | r | asusd's curves per profile and fan | +| `zephyrus_check` | r | every expectation: model, packages (local or foreign), daemons, nvidia-powerd, sleep units, the NVIDIA options **in force** (`/proc/driver/nvidia/params`), charge limit, one authority each over the profile and the GPU mode, predecessor leftovers. It also lists what it did not check | + +`profile` is a candidate verb for a future `node-power-profile` seat (research 027/03). That seat has +no record yet, so this is the module's own tool. + +## Found on the laptop, 2026-10-04 (read-only) + +- **Two authorities over the GPU mode.** `asusd.ron` has `ac_command: "supergfxctl -m Hybrid"` and + `bat_command: "supergfxctl -m Integrated"`, so asusd switches the GPU mode on every change of power + source. **supergfxd 5.2.7 cannot read logind's sessions** (`manager is an invalid variant`, every + boot), so a switch that needs a logout times out. `zephyrus_check` reports both. The fix is the + operator's, in asusd's file: clear both commands, or update supergfxctl once it can be packaged. +- **`brightness.conf` did nothing.** `HandleBrightnessKey` is not a logind key, and logind logs + *Unknown key … ignoring* at every start. The module does not carry it. The brightness keys were + always triggerhappy's, with the ACPI video switch off. +- **Two profile switchers** would run at once until `auto-profile` is stopped (below). +- **asusctl is a local build** (6.4.0, *Unknown Packager*) beside a foreign `asusctl-debug`. + +## When assigned to the laptop: what changes + +1. `/usr/local/lib/asus-zephyrus-g14/` appears (four scripts). +2. Written over found files (each original kept once by the host): `g14-nvidia-power.conf` and + `video-brightness-switch.conf` (same options, so no change until the next boot either), + `logind.conf.d/power.conf` (same keys; logind reloaded), `90-backlight.rules` (same effect; + udevd reloaded), `triggers.d/asus-g14.conf` (now the module's scripts). +3. New: the three sleep drop-ins (behaviour gained: `nvidia-suspend-then-hibernate`), the + nvidia-powerd drop-in (no effect while it is masked), the triggerhappy drop-in, the UPower drop-in + (same values as today), and the touchpad input class (at the next X start). +4. `daemon-reload` and a `triggerhappy` restart. thd now runs as the account and the keys run the + module's scripts. `upower` restarts. +5. Packages, asusd and supergfxd: already as declared, so nothing changes. asusctl stays the local + 6.4.0 until the next upgrade. +6. The node runtime restarts with the new bundle. The switcher asserts the limit (80, already) and + asusd's profiles (Balanced and Quiet, already), so it sets nothing. It takes the current decision + as applied and acts from the first event on. + +## Predecessor files this module makes redundant — the operator removes them once (ADR 0182) + +**On the laptop:** + +1. `systemctl --user disable --now auto-profile.service`, then delete + `~/.config/systemd/user/auto-profile.service` and `~/scripts/auto-profile`. **Do this right after + the push**, or two switchers run at once. +2. `~/scripts/asus-bright`, `~/scripts/asusctl-kbd-bright`, `~/scripts/xrandr-bright`, + `~/scripts/as-user`, `~/scripts/media-control`: no trigger and no i3 binding uses them any more. +3. `~/scripts/xinput-reset-touchpad`: still started and bound by `~/.config/i3/config.d/20-g14.conf`. + Point those two lines at `/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset`, or wait for + the i3 module (phase 2) to rewrite the fragment. +4. `/etc/systemd/system/triggerhappy.service`: the predecessor's replacement of the packaged unit. The + module's drop-in works over either, so delete it and `systemctl daemon-reload` to return to the + packaged unit (`Type=notify`, socket). +5. `/etc/systemd/logind.conf.d/brightness.conf`: the unknown key, which does nothing. +6. `/etc/systemd/system-sleep/xinput-reset-touchpad.sh`: replaced by the input class. +7. `/etc/UPower/UPower.conf`: the predecessor's replacement of the package's file. Its values are now + the module's drop-in. Restore the package's copy (`rm` it, then `pacman -S upower`). +8. Optional: `systemctl disable nvidia-suspend nvidia-resume nvidia-hibernate` (the drop-ins carry them + now), `/etc/asusd/*.ron-old` and `fan_curves.ron.bak`, the foreign `asusctl-debug` package, and + `pacman -S asusctl` for the distribution's build. + +**Stays the machine's:** `/swapfile` and `/etc/systemd/system/swapfile.swap` (the swap layout), +`/etc/udev/rules.d/91-monitor-hotplug.rules` (the display's, phase 2), and the i3 fragments. + +**On the desktop** (the predecessor's G14 flavor reached it; part was removed on 2026-10-04): none of +this module applies there. Still present and to be deleted: +`/etc/systemd/logind.conf.d/brightness.conf`, `/etc/systemd/system-sleep/xinput-reset-touchpad.sh`, +`~/scripts/xinput-reset-touchpad`, `~/scripts/xrandr-bright` and +`~/.config/i3/scripts/kbd-brightness-notify.sh`. + +## Tests + +`go test ./...` in this directory. Every tool runs against a tree standing in for `/sys`, `/proc` and +`/etc`, and an injected runner answering with what asusctl 6.4 and supergfxctl 5.2 said on the laptop. +The tests cover: + +- the power-source rule; +- battery arithmetic from `charge_*`; +- the eDP panel choice; +- brightness bounds; +- the policy's sustain, relax and hysteresis, with iowait counted as idle; +- the switcher: no act at start, one switch per change of source, a published event, boost from + samples, holds, retry after failure, start-up assertions only where they differ, inert on another + model; +- the uevent filter; +- the manifest: tools listed equal tools served, no machine named, triggers exist, every key runs a + shipped executable script, every script passes `bash -n`. diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/asus.go b/modules/asus-zephyrus-g14/cmd/zephyrus/asus.go new file mode 100644 index 0000000..9dacf42 --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/asus.go @@ -0,0 +1,264 @@ +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

`. +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 +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/asus_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/asus_test.go new file mode 100644 index 0000000..c32f1b1 --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/asus_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "context" + "strings" + "testing" +) + +// What asusctl 6.4 and supergfxctl 5.2 said on the laptop on 2026-10-04. +const fanCurveQuiet = ` +Fan curves for Quiet + +[ + ( + fan: CPU, + pwm: (2, 0, 10, 20, 35, 55, 80, 100), + temp: (35, 45, 50, 55, 60, 65, 70, 80), + enabled: true, + ), + ( + fan: GPU, + pwm: (0, 0, 10, 20, 35, 65, 90, 115), + temp: (35, 45, 50, 55, 60, 65, 70, 80), + enabled: false, + ), +] +` + +const asusdRon = `( + charge_control_end_threshold: 80, + base_charge_control_end_threshold: 0, + disable_nvidia_powerd_on_battery: true, + ac_command: "supergfxctl -m Hybrid", + bat_command: "supergfxctl -m Integrated", + platform_profile_linked_epp: true, + platform_profile_on_battery: Quiet, + change_platform_profile_on_battery: true, + platform_profile_on_ac: Balanced, + change_platform_profile_on_ac: true, + ac_profile_tunings: { + Quiet: ( + enabled: false, + group: {}, + ), + }, +)` + +func TestAsusctlsAnswersAreRead(t *testing.T) { + p, err := ParseProfileGet(profileGetBalanced) + if err != nil || p.Active != "Balanced" || p.OnAC != "Balanced" || p.Battery != "Quiet" { + t.Fatalf("%+v %v", p, err) + } + if _, err := ParseProfileGet("something else"); err == nil { + t.Fatal("an answer with no profile was read as one") + } + if l, err := ParseLeds("Current keyboard led brightness: High\n"); err != nil || l != "high" { + t.Fatalf("%q %v", l, err) + } + curves := ParseFanCurves(fanCurveQuiet) + if len(curves) != 2 || curves[0].Fan != "CPU" || curves[0].PWM[7] != 100 || curves[0].Temp[0] != 35 || curves[1].Enabled { + t.Fatalf("%+v", curves) + } + if got := ParseSupported("[Integrated, Hybrid, AsusMuxDgpu]\n"); strings.Join(got, ",") != "Integrated,Hybrid,AsusMuxDgpu" { + t.Fatalf("%v", got) + } +} + +func TestAsusdsFileIsReadForWhatDecidesTheModulesCodeAndNothingNested(t *testing.T) { + c := ParseAsusdRon(asusdRon) + if *c.ChargeLimit != 80 || c.ProfileOnAC != "Balanced" || c.ProfileOnBattery != "Quiet" || + c.ACCommand != "supergfxctl -m Hybrid" || c.BatteryCommand != "supergfxctl -m Integrated" || + !*c.ChangesProfileOnAC || !*c.DisablesPowerdOnBatt { + t.Fatalf("%+v", c) + } +} + +func TestAMissingVendorClientIsNamedWithWhyItIsMissing(t *testing.T) { + f := newFake(t) + f.fails["supergfxctl"] = notFound + _, err := f.machine().GPU(context.Background()) + if err == nil || !strings.Contains(err.Error(), "research 027") { + t.Fatalf("%v", err) + } + f.fails["asusctl"] = notFound + _, err = f.machine().Profile(context.Background()) + if err == nil || !strings.Contains(err.Error(), "package resource installs it") { + t.Fatalf("%v", err) + } +} + +func TestAGPUModeIsSetOnlyWhenTheMachineSupportsItAndAsusdsSwitchingIsSaid(t *testing.T) { + f := newFake(t) + f.answers["supergfxctl -g"] = "Hybrid\n" + f.answers["supergfxctl -s"] = "[Integrated, Hybrid, AsusMuxDgpu]\n" + f.file("/etc/asusd/asusd.ron", asusdRon) + m := f.machine() + if _, err := GPUModeTool(context.Background(), m, map[string]any{"mode": "Vfio"}); err == nil { + t.Fatal("an unsupported mode was accepted") + } + out, err := GPUModeTool(context.Background(), m, map[string]any{"mode": "integrated"}) + if err != nil { + t.Fatal(err) + } + if !f.called("supergfxctl -m Integrated") { + t.Fatalf("calls %v", f.calls) + } + if _, said := out.(map[string]any)["asusd_switches_it"]; !said { + t.Fatalf("asusd's own switching of the mode was not said: %+v", out) + } +} + +func TestTheChargeLimitIsBoundedAndSetThroughAsusd(t *testing.T) { + f := newFake(t) + f.answers["asusctl battery info"] = "Current battery charge limit: 60%\n" + m := f.machine() + for _, bad := range []any{float64(10), float64(101), "x", 55.5} { + if _, err := ChargeLimitTool(context.Background(), m, map[string]any{"limit": bad}); err == nil { + t.Errorf("limit %v was accepted", bad) + } + } + out, err := ChargeLimitTool(context.Background(), m, map[string]any{"limit": float64(60)}) + if err != nil || !f.called("asusctl battery limit 60") || out.(map[string]any)["asusd_limit_percent"] != 60 { + t.Fatalf("%+v %v %v", out, err, f.calls) + } +} + +func TestAProfileSetByToolIsHeldAndAnUnknownOneRefused(t *testing.T) { + f := newFake(t) + f.onMains() + f.answers["asusctl profile get"] = profileGetBalanced + m := f.machine() + sw := NewSwitcher(m, nil) + if _, err := ProfileTool(context.Background(), m, sw, map[string]any{"profile": "Turbo"}); err == nil { + t.Fatal("an unknown profile was accepted") + } + out, err := ProfileTool(context.Background(), m, sw, map[string]any{"profile": "performance", "hold_minutes": float64(30)}) + if err != nil || !f.called("asusctl profile set Performance") { + t.Fatalf("%v %v", err, f.calls) + } + if _, held := out.(map[string]any)["held_until"]; !held { + t.Fatalf("not held: %+v", out) + } + if r := sw.Report(); r.Held != "Performance" { + t.Fatalf("%+v", r) + } +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/check.go b/modules/asus-zephyrus-g14/cmd/zephyrus/check.go new file mode 100644 index 0000000..727c994 --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/check.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Check is one thing the module expects of the machine, and whether it holds. +type Check struct { + Name string `json:"name"` + OK bool `json:"ok"` + Detail string `json:"detail"` +} + +// CheckReport is what zephyrus_check answers. NotChecked says what it did not look at, because a +// check that reads as clean while skipping something is the predecessor's verifier again. +type CheckReport struct { + Model string `json:"model"` + Checks []Check `json:"checks"` + Failing int `json:"failing"` + NotChecked []string `json:"not_checked"` +} + +var ( + pacmanVersion = regexp.MustCompile(`(?m)^Version\s*:\s*(\S+)`) + pacmanPackager = regexp.MustCompile(`(?m)^Packager\s*:\s*(.+)$`) + nvidiaParam = regexp.MustCompile(`(?m)^(\w+):\s*(\S+)`) +) + +// ParseNvidiaParams reads /proc/driver/nvidia/params. +func ParseNvidiaParams(text string) map[string]string { + out := map[string]string{} + for _, g := range nvidiaParam.FindAllStringSubmatch(text, -1) { + out[g[1]] = g[2] + } + return out +} + +// predecessorProcess finds a running process whose command line names the predecessor's script. +func (m *Machine) predecessorProcess(name string) (int, bool) { + for _, dir := range m.glob("/proc/[0-9]*") { + cmd := strings.ReplaceAll(m.read(dir+"/cmdline"), "\x00", " ") + if strings.Contains(cmd, "/"+name) && !strings.Contains(cmd, "zephyrus") { + var pid int + fmt.Sscanf(filepath.Base(dir), "%d", &pid) + return pid, true + } + } + return 0, false +} + +func (m *Machine) unitIs(ctx context.Context, verb, unit string) string { + out, _ := m.Run(ctx, "systemctl", verb, unit) + return strings.TrimSpace(out) +} + +// Check reads every expectation and reports each. +func (m *Machine) Check(ctx context.Context, sw *Switcher) CheckReport { + r := CheckReport{Model: m.Model(), NotChecked: []string{ + "the fan curves (asusd's own, read them with zephyrus_fan_curves)", + "whether the initramfs carries the NVIDIA options (they are read from the running driver instead)", + "the vendor keys themselves (press them)", + }} + add := func(name string, ok bool, format string, a ...any) { + r.Checks = append(r.Checks, Check{Name: name, OK: ok, Detail: fmt.Sprintf(format, a...)}) + if !ok { + r.Failing++ + } + } + add("model", m.ThisModel(), "the firmware reports %q; this module is for %q", r.Model, ModelFamily) + + // asusctl: present, and from the distribution rather than a local build. + if info, err := m.Run(ctx, "pacman", "-Qi", "asusctl"); err != nil { + add("asusctl package", false, "not installed: %v", err) + } else { + v, p := "", "" + if g := pacmanVersion.FindStringSubmatch(info); g != nil { + v = g[1] + } + if g := pacmanPackager.FindStringSubmatch(info); g != nil { + p = strings.TrimSpace(g[1]) + } + local := p == "Unknown Packager" + add("asusctl package", !local, "version %s, packager %s%s", v, p, + map[bool]string{true: "; a local build — the distribution's package replaces it at the next upgrade (pacman -S asusctl)", false: ""}[local]) + } + for _, foreign := range []string{"supergfxctl", "triggerhappy"} { + _, err := m.Run(ctx, "pacman", "-Q", foreign) + add(foreign+" package", err == nil, "%s; not in the distribution's repositories, kept as found (novox/hq research 027, question 1)", + map[bool]string{true: "installed", false: "NOT installed"}[err == nil]) + } + for _, unit := range []string{"asusd.service", "supergfxd.service", "triggerhappy.service"} { + state := m.unitIs(ctx, "is-active", unit) + add(unit, state == "active", "%s", state) + } + powerd := m.unitIs(ctx, "is-enabled", "nvidia-powerd.service") + add("nvidia-powerd.service", powerd == "masked" || powerd == "disabled" || powerd == "" || strings.Contains(powerd, "not-found"), + "%s; the module's drop-in keeps it from starting unless the kernel command line says zephyrus.nvidia-powerd", orNone(powerd)) + wants, _ := m.Run(ctx, "systemctl", "show", "-p", "Wants", "systemd-suspend.service") + add("nvidia suspend and resume", strings.Contains(wants, "nvidia-suspend.service") && strings.Contains(wants, "nvidia-resume.service"), + "systemd-suspend.service %s", strings.TrimSpace(wants)) + + params := ParseNvidiaParams(m.read("/proc/driver/nvidia/params")) + if len(params) == 0 { + add("nvidia options", false, "the NVIDIA driver is not loaded (no /proc/driver/nvidia/params)") + } else { + add("nvidia options", params["PreserveVideoMemoryAllocations"] == "1" && params["DynamicPowerManagement"] == "0", + "PreserveVideoMemoryAllocations=%s DynamicPowerManagement=%s (want 1 and 0; a change applies when the driver loads again)", + params["PreserveVideoMemoryAllocations"], params["DynamicPowerManagement"]) + } + + for _, b := range m.Batteries() { + ok := b.LimitPercent != nil && *b.LimitPercent == ChargeLimitPercent + have := "unknown" + if b.LimitPercent != nil { + have = fmt.Sprintf("%d%%", *b.LimitPercent) + } + add("charge limit", ok, "%s is %s, the module's is %d%%", b.Name, have, ChargeLimitPercent) + } + + if c := m.Asusd(); c != nil && (c.ACCommand != "" || c.BatteryCommand != "") { + add("one authority over the GPU mode", false, + "asusd runs %q on mains and %q on battery: it switches the GPU mode on every change of power source, "+ + "so a mode set with zephyrus_gpu_mode lasts until the next one. Clear ac_command and bat_command in /etc/asusd/asusd.ron (asusd's file) to make it the operator's alone", + c.ACCommand, c.BatteryCommand) + } + if out, err := m.Run(ctx, "journalctl", "-b", "-u", "supergfxd.service", "-g", "invalid variant", "-n", "1", "-q", "-o", "cat"); err == nil && strings.TrimSpace(out) != "" { + add("supergfxd and logind", false, "supergfxd cannot read logind's sessions this boot (%s): a mode change that needs a logout times out", strings.TrimSpace(out)) + } + + if pid, ok := m.predecessorProcess("auto-profile"); ok { + add("one profile switcher", false, "the predecessor's auto-profile still runs (pid %d) and switches the profile every five seconds; "+ + "stop it: systemctl --user disable --now auto-profile.service", pid) + } else { + add("one profile switcher", true, "no predecessor auto-profile is running") + } + if sw != nil { + rep := sw.Report() + add("profile switcher", rep.Running, "%s", orNone(firstNonEmpty(rep.Disabled, rep.LastError, "woken by "+rep.Watching))) + } + if home := os.Getenv("MESH_OPERATOR_HOME"); home != "" { + var left []string + for _, p := range PredecessorHomeFiles { + if _, err := os.Stat(filepath.Join(m.Root, home, p)); err == nil { + left = append(left, "~/"+p) + } + } + add("predecessor files in the home", len(left) == 0, "%s", orNone(strings.Join(left, ", "))) + } else { + r.NotChecked = append(r.NotChecked, "the predecessor's files in the operator's home (MESH_OPERATOR_HOME is not set)") + } + return r +} + +// PredecessorHomeFiles are what the predecessor placed in the operator's home for this model and this +// module replaces. The mesh removes nothing it did not make (novox/hq ADR 0182): the operator does, +// once, and this list is how the check knows. +var PredecessorHomeFiles = []string{ + "scripts/auto-profile", + ".config/systemd/user/auto-profile.service", + "scripts/asus-bright", + "scripts/asusctl-kbd-bright", + "scripts/xrandr-bright", +} + +func orNone(s string) string { + if strings.TrimSpace(s) == "" { + return "none" + } + return s +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/display.go b/modules/asus-zephyrus-g14/cmd/zephyrus/display.go new file mode 100644 index 0000000..aae5e06 --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/display.go @@ -0,0 +1,202 @@ +package main + +import ( + "context" + "fmt" + "math" + "os" + "path" + "sort" + "strconv" + "strings" +) + +// MinPanelPercent is the floor a brightness change never goes below: a panel at zero is a black +// screen that looks like a dead machine, and the keys cannot be seen to bring it back. +const MinPanelPercent = 1 + +// Panel is the internal display's backlight. +type Panel struct { + Device string `json:"device"` + Percent float64 `json:"percent"` + Raw int64 `json:"raw"` + Max int64 `json:"max"` + Others []string `json:"other_backlights,omitempty"` +} + +// panelDevice chooses the backlight that drives the internal panel. +// +// **This model registers two.** In hybrid mode the integrated GPU drives the panel (amdgpu_bl1, +// beneath the eDP connector) and the discrete GPU's driver registers one of its own (nvidia_0) that +// moves nothing. The predecessor's scripts named amdgpu_bl1 literally, which is right until the GPU +// mode puts the panel on the other GPU. The one that sits under an eDP connector is the panel's; failing +// that, the kernel's own preference: firmware, then platform, then raw. +func (m *Machine) panelDevice() (string, []string, error) { + all := m.glob("/sys/class/backlight/*") + if len(all) == 0 { + return "", nil, fmt.Errorf("this machine has no backlight in /sys/class/backlight") + } + names := make([]string, 0, len(all)) + for _, d := range all { + names = append(names, path.Base(d)) + } + sort.Strings(names) + rank := func(name string) int { + dir := "/sys/class/backlight/" + name + if target, err := os.Readlink(m.path(dir)); err == nil && strings.Contains(target, "-eDP-") { + return 0 + } + switch m.read(dir + "/type") { + case "firmware": + return 1 + case "platform": + return 2 + } + return 3 + } + best := names[0] + for _, n := range names[1:] { + if rank(n) < rank(best) { + best = n + } + } + var others []string + for _, n := range names { + if n != best { + others = append(others, n) + } + } + return best, others, nil +} + +// PanelBrightness reads the panel. +func (m *Machine) PanelBrightness() (Panel, error) { + dev, others, err := m.panelDevice() + if err != nil { + return Panel{}, err + } + dir := "/sys/class/backlight/" + dev + raw, ok1 := m.readInt(dir + "/brightness") + max, ok2 := m.readInt(dir + "/max_brightness") + if !ok1 || !ok2 || max <= 0 { + return Panel{}, fmt.Errorf("%s does not say its brightness", dir) + } + return Panel{Device: dev, Raw: raw, Max: max, Percent: round1(float64(raw) / float64(max) * 100), Others: others}, nil +} + +// PanelTarget turns a request — "40", "40%", "+5", "-10" — into the percentage to set, clamped to +// [MinPanelPercent, 100]. +func PanelTarget(current float64, request string) (float64, error) { + r := strings.TrimSuffix(strings.TrimSpace(request), "%") + if r == "" { + return 0, fmt.Errorf("panel needs a percentage (40) or a step (+5, -5)") + } + n, err := strconv.ParseFloat(r, 64) + if err != nil || math.IsNaN(n) || math.IsInf(n, 0) { + return 0, fmt.Errorf("panel %q is not a percentage or a step", request) + } + target := n + if strings.HasPrefix(r, "+") || strings.HasPrefix(r, "-") { + target = current + n + } + return math.Max(MinPanelPercent, math.Min(100, target)), nil +} + +// SetPanel sets the panel to a percentage. +func (m *Machine) SetPanel(ctx context.Context, request string) (Panel, error) { + p, err := m.PanelBrightness() + if err != nil { + return p, err + } + target, err := PanelTarget(p.Percent, request) + if err != nil { + return p, err + } + raw := int64(math.Round(target / 100 * float64(p.Max))) + if raw < 1 { + raw = 1 + } + if err := m.write(ctx, "/sys/class/backlight/"+p.Device+"/brightness", strconv.FormatInt(raw, 10)); err != nil { + return p, err + } + return m.PanelBrightness() +} + +// Keyboard is the keyboard's backlight. +type Keyboard struct { + Device string `json:"device"` + Level string `json:"level"` + Value int64 `json:"value"` + Max int64 `json:"max"` +} + +// KeyboardBrightness reads the keyboard backlight from the kernel. +func (m *Machine) KeyboardBrightness() (Keyboard, error) { + found := m.glob("/sys/class/leds/*kbd_backlight*") + if len(found) == 0 { + return Keyboard{}, fmt.Errorf("this machine has no keyboard backlight in /sys/class/leds") + } + dir := found[0] + v, ok1 := m.readInt(dir + "/brightness") + max, ok2 := m.readInt(dir + "/max_brightness") + if !ok1 || !ok2 { + return Keyboard{}, fmt.Errorf("%s does not say its brightness", dir) + } + k := Keyboard{Device: path.Base(dir), Value: v, Max: max} + if max == int64(len(KeyboardLevels)-1) && v >= 0 && v <= max { + k.Level = KeyboardLevels[v] + } + return k, nil +} + +// KeyboardTarget turns a request — off/low/med/high, 0-3, "+", "-" — into asusd's level name. +func KeyboardTarget(current int64, request string) (string, error) { + r := strings.ToLower(strings.TrimSpace(request)) + switch r { + case "medium": + r = "med" + case "+", "up": + r = strconv.FormatInt(min64(current+1, int64(len(KeyboardLevels)-1)), 10) + case "-", "down": + r = strconv.FormatInt(max64(current-1, 0), 10) + } + for _, l := range KeyboardLevels { + if r == l { + return l, nil + } + } + if n, err := strconv.Atoi(r); err == nil && n >= 0 && n < len(KeyboardLevels) { + return KeyboardLevels[n], nil + } + return "", fmt.Errorf("keyboard %q is not one of off, low, med, high, 0-3, + or -", request) +} + +// SetKeyboard has asusd set the keyboard backlight, so its own record of the level stays true. +func (m *Machine) SetKeyboard(ctx context.Context, request string) (Keyboard, error) { + k, err := m.KeyboardBrightness() + if err != nil { + return k, err + } + level, err := KeyboardTarget(k.Value, request) + if err != nil { + return k, err + } + if _, err := m.Run(ctx, "asusctl", "leds", "set", level); err != nil { + return k, vendor("asusctl", err) + } + return m.KeyboardBrightness() +} + +func min64(a, b int64) int64 { + if a < b { + return a + } + return b +} + +func max64(a, b int64) int64 { + if a > b { + return a + } + return b +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/display_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/display_test.go new file mode 100644 index 0000000..dd6954f --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/display_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" +) + +// backlight makes a backlight the way sysfs does: a link from /sys/class/backlight into the device +// tree, which is where the eDP connector shows. +func (f *fake) backlight(name, device string, raw, max string) { + dev := "/sys/devices/" + device + "/" + name + f.file(dev+"/brightness", raw) + f.file(dev+"/max_brightness", max) + f.file(dev+"/type", "raw") + link := filepath.Join(f.root, "/sys/class/backlight", name) + os.MkdirAll(filepath.Dir(link), 0o755) + if err := os.Symlink(filepath.Join(f.root, dev), link); err != nil { + f.t.Fatal(err) + } +} + +func TestThePanelIsTheBacklightUnderTheEDPConnectorNotTheDiscreteGPUs(t *testing.T) { + f := newFake(t) + f.backlight("amdgpu_bl1", "pci0000:00/0000:65:00.0/drm/card1/card1-eDP-1", "199500", "399000") + f.backlight("nvidia_0", "pci0000:00/0000:01:00.0/backlight", "100", "100") + p, err := f.machine().PanelBrightness() + if err != nil { + t.Fatal(err) + } + if p.Device != "amdgpu_bl1" || p.Percent != 50 || len(p.Others) != 1 || p.Others[0] != "nvidia_0" { + t.Fatalf("%+v", p) + } +} + +func TestAPanelRequestIsAPercentageOrAStepAndNeverGoesDark(t *testing.T) { + for _, c := range []struct { + cur float64 + req string + want float64 + }{{50, "40", 40}, {50, "40%", 40}, {50, "+5", 55}, {50, "-10", 40}, {3, "-10", 1}, {98, "+5", 100}, {50, "0", 1}} { + got, err := PanelTarget(c.cur, c.req) + if err != nil || got != c.want { + t.Errorf("%v %q: %v %v, want %v", c.cur, c.req, got, err, c.want) + } + } + for _, bad := range []string{"", "bright", "NaN"} { + if _, err := PanelTarget(50, bad); err == nil { + t.Errorf("%q was accepted", bad) + } + } +} + +func TestSettingThePanelWritesTheRawValue(t *testing.T) { + f := newFake(t) + f.backlight("amdgpu_bl1", "card1-eDP-1", "399000", "399000") + p, err := f.machine().SetPanel(context.Background(), "25") + if err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(filepath.Join(f.root, "/sys/devices/card1-eDP-1/amdgpu_bl1/brightness")) + if strings.TrimSpace(string(raw)) != "99750" || p.Percent != 25 { + t.Fatalf("wrote %q, read back %+v", raw, p) + } +} + +func TestTheKeyboardIsSetThroughAsusdByLevel(t *testing.T) { + f := newFake(t) + f.file("/sys/class/leds/asus::kbd_backlight/brightness", "1") + f.file("/sys/class/leds/asus::kbd_backlight/max_brightness", "3") + k, err := f.machine().KeyboardBrightness() + if err != nil || k.Level != "low" { + t.Fatalf("%+v %v", k, err) + } + if _, err := f.machine().SetKeyboard(context.Background(), "+"); err != nil { + t.Fatal(err) + } + if !f.called("asusctl leds set med") { + t.Fatalf("calls: %v", f.calls) + } + for req, want := range map[string]string{"high": "high", "0": "off", "medium": "med", "-": "off"} { + if got, err := KeyboardTarget(1, req); err != nil || got != want { + t.Errorf("%q: %q %v", req, got, err) + } + } + if _, err := KeyboardTarget(1, "7"); err == nil { + t.Error("level 7 was accepted") + } +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/helpers_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/helpers_test.go new file mode 100644 index 0000000..91b35d7 --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/helpers_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" +) + +// fake is a machine for a test: a tree standing in for /, and a runner answering from a table and +// recording every command it was asked to run. +type fake struct { + t *testing.T + root string + mu sync.Mutex + answers map[string]string + fails map[string]error + calls []string +} + +func newFake(t *testing.T) *fake { + t.Helper() + return &fake{t: t, root: t.TempDir(), answers: map[string]string{}, fails: map[string]error{}} +} + +func (f *fake) machine() *Machine { return &Machine{Root: f.root, Run: f.run} } + +func (f *fake) run(_ context.Context, name string, args ...string) (string, error) { + line := strings.TrimSpace(name + " " + strings.Join(args, " ")) + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, line) + if err, ok := f.fails[line]; ok { + return "", err + } + if out, ok := f.answers[line]; ok { + return out, nil + } + if err, ok := f.fails[name]; ok { + return "", err + } + return "", nil +} + +func (f *fake) called(line string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.calls { + if c == line { + return true + } + } + return false +} + +func (f *fake) callsLike(prefix string) []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, c := range f.calls { + if strings.HasPrefix(c, prefix) { + out = append(out, c) + } + } + return out +} + +// file writes a file under the fake root. +func (f *fake) file(path, content string) { + f.t.Helper() + full := filepath.Join(f.root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + f.t.Fatal(err) + } +} + +// supply writes one power supply's attributes. +func (f *fake) supply(name string, attrs map[string]string) { + for k, v := range attrs { + f.file("/sys/class/power_supply/"+name+"/"+k, v+"\n") + } +} + +// onMains and onBattery are this model's two states as measured on 2026-10-04. +func (f *fake) onMains() { + f.supply("ACAD", map[string]string{"type": "Mains", "online": "1"}) + f.supply("BAT1", map[string]string{"type": "Battery", "status": "Not charging", "capacity": "80"}) +} + +func (f *fake) onBattery() { + f.supply("ACAD", map[string]string{"type": "Mains", "online": "0"}) + f.supply("BAT1", map[string]string{"type": "Battery", "status": "Discharging", "capacity": "79"}) +} + +var notFound = &exec.Error{Name: "x", Err: exec.ErrNotFound} + +const profileGetBalanced = "Active profile: Balanced\n\nAC profile Balanced\nBattery profile Quiet\n" diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/machine.go b/modules/asus-zephyrus-g14/cmd/zephyrus/machine.go new file mode 100644 index 0000000..911d7af --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/machine.go @@ -0,0 +1,124 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// CommandTimeout bounds every command a tool or the switcher runs: a vendor daemon that hangs on its +// bus must cost a tool call twenty seconds, never the runtime's thirty. +const CommandTimeout = 20 * time.Second + +// Runner runs one command and answers its standard output. It is injected so that every tool is +// tested against recorded answers rather than this machine's daemons. +type Runner func(ctx context.Context, name string, args ...string) (string, error) + +// ExecRunner runs a command on the machine, bounded by CommandTimeout. A failure carries what the +// command said on stderr, because "exit status 1" names nothing. +func ExecRunner(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, CommandTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + if ctx.Err() == context.DeadlineExceeded { + return stdout.String(), fmt.Errorf("%s did not answer within %s", name, CommandTimeout) + } + if err != nil { + said := strings.TrimSpace(stderr.String()) + if said == "" { + said = strings.TrimSpace(stdout.String()) + } + if said != "" { + return stdout.String(), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, said) + } + return stdout.String(), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return stdout.String(), nil +} + +// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree of +// /sys and /proc and /etc) and a way to run commands. +type Machine struct { + Root string + Run Runner +} + +// Here is the machine this process runs on. +func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner} } + +func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) } + +// read is a file's content, trimmed; "" when it cannot be read. +func (m *Machine) read(p string) string { + b, err := os.ReadFile(m.path(p)) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// readInt is a file holding one integer; ok false when it is absent or not a number. +func (m *Machine) readInt(p string) (int64, bool) { + s := m.read(p) + if s == "" { + return 0, false + } + n, err := strconv.ParseInt(s, 10, 64) + return n, err == nil +} + +func (m *Machine) glob(pattern string) []string { + found, _ := filepath.Glob(m.path(pattern)) + out := make([]string, 0, len(found)) + for _, f := range found { + rel, err := filepath.Rel(m.Root, f) + if err != nil { + continue + } + out = append(out, "/"+filepath.ToSlash(rel)) + } + return out +} + +// write puts a value into a file of the kernel's (a backlight). Where the account may not write it +// — the udev rule that gives the video group the panel has not run yet — it escalates with `sudo -n`, +// which never prompts: the operator's account may escalate without one, and when it may not, the +// tool says so in sudo's words. +func (m *Machine) write(ctx context.Context, p, value string) error { + err := os.WriteFile(m.path(p), []byte(value), 0) + if err == nil { + return nil + } + if !errors.Is(err, os.ErrPermission) { + return err + } + if _, serr := m.Run(ctx, "sudo", "-n", "sh", "-c", `printf '%s' "$1" > "$2"`, "sh", value, m.path(p)); serr != nil { + return fmt.Errorf("%s is not writable by this account and sudo -n refused: %v", p, serr) + } + return nil +} + +// notInstalled says a command failed because it is not on this machine at all. +func notInstalled(err error) bool { return errors.Is(err, exec.ErrNotFound) } + +// round to one decimal, for watts and percentages a person reads. +func round1(f float64) float64 { + return float64(int64(f*10+sign(f)*0.5)) / 10 +} + +func sign(f float64) float64 { + if f < 0 { + return -1 + } + return 1 +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/main.go b/modules/asus-zephyrus-g14/cmd/zephyrus/main.go new file mode 100644 index 0000000..2bc5303 --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/main.go @@ -0,0 +1,25 @@ +// The asus-zephyrus-g14 module's Go bundle (novox/hq ADR 0188, ADR 0193, ADR 0198): one process the +// node's runtime launches, serving the module's tools over MCP on stdio and running its long-running +// code — the platform-profile switcher — beside them. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + m := Here() + sw := NewSwitcher(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go sw.Run(ctx) + // An empty name serves as the module the runtime names (MESH_SERVED_MODULE). + if err := stdio.Serve("", Tools(m, sw)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go new file mode 100644 index 0000000..ea7490a --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/manifest_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "testing" +) + +type manifest struct { + Module string `json:"module"` + Tools []string `json:"tools"` + Emits []string `json:"emits"` + Resources []map[string]any `json:"resources"` +} + +func readManifest(t *testing.T) manifest { + t.Helper() + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + return m +} + +func TestTheManifestNamesExactlyTheToolsTheBundleServes(t *testing.T) { + m := readManifest(t) + var served []string + for _, tool := range Tools(&Machine{Root: t.TempDir(), Run: newFake(t).run}, nil) { + served = append(served, tool.Name) + } + sort.Strings(served) + listed := append([]string(nil), m.Tools...) + sort.Strings(listed) + if strings.Join(served, ",") != strings.Join(listed, ",") { + t.Fatalf("served %v, listed %v", served, listed) + } + if len(m.Emits) != 1 || m.Emits[0] != "profile.switched" { + t.Fatalf("emits %v", m.Emits) + } +} + +// The module names the model, never a node, a person or a user id (novox/hq ADR 0112), and every +// trigger it names a service restart or reload on is one of its own resources. +func TestTheManifestNamesNoMachineAndItsTriggersExist(t *testing.T) { + m := readManifest(t) + ids := map[string]bool{} + for _, r := range m.Resources { + ids[r["id"].(string)] = true + } + raw, _ := os.ReadFile("../../module.json") + for _, banned := range []string{"jochen", "/home/", "/run/user/1000", "\"g14\"", "shanks"} { + if strings.Contains(string(raw), banned) { + t.Errorf("the manifest says %q", banned) + } + } + for _, r := range m.Resources { + for _, key := range []string{"restart-on", "reload-on"} { + list, _ := r[key].([]any) + for _, id := range list { + if !ids[id.(string)] { + t.Errorf("%s %s names %v, which is not a resource", r["id"], key, id) + } + } + } + } +} + +// Every trigger runs a script the module ships, and every script parses. +func TestTheVendorKeysRunTheModulesOwnScriptsAndTheyParse(t *testing.T) { + m := readManifest(t) + var triggers string + for _, r := range m.Resources { + if r["id"] == "vendor-keys" { + triggers = r["content"].(string) + } + } + if triggers == "" { + t.Fatal("no vendor-keys resource") + } + for _, line := range strings.Split(triggers, "\n") { + f := strings.Split(line, "\t") + if strings.HasPrefix(line, "#") || len(f) < 3 { + continue + } + script := strings.Fields(f[2])[0] + local := filepath.Join("../../files/bin", filepath.Base(script)) + if !strings.HasPrefix(script, "/usr/local/lib/asus-zephyrus-g14/bin/") { + t.Errorf("%s runs %s, which the module does not ship", f[0], script) + } else if st, err := os.Stat(local); err != nil || st.Mode()&0o111 == 0 { + t.Errorf("%s: %s is missing or not executable", f[0], local) + } + } + scripts, _ := filepath.Glob("../../files/bin/*") + if len(scripts) == 0 { + t.Fatal("no scripts") + } + for _, s := range scripts { + if out, err := exec.Command("bash", "-n", s).CombinedOutput(); err != nil { + t.Errorf("%s: %v %s", s, err, out) + } + } +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/policy.go b/modules/asus-zephyrus-g14/cmd/zephyrus/policy.go new file mode 100644 index 0000000..ad2c03d --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/policy.go @@ -0,0 +1,137 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +// The policy, as constants until the mesh has settings a module can read (novox/hq issue 168). The +// values are the predecessor's, made explicit, and two of its behaviours are changed on purpose: +// +// - **Sustained, not momentary.** The predecessor boosted on one five-second sample above 50 %: a +// compile's first second, a browser's tab restore. Here the load must stay above the line for +// SustainSamples samples in a row, and below the lower line as long, before the profile moves. +// - **Waiting on a disk is not load.** iowait is counted as idle: a machine stalled on its SSD does +// not get faster with a higher power limit, only hotter. +const ( + ProfileOnBattery = "Quiet" + ProfileOnAC = "Balanced" + ProfileUnderLoad = "Performance" + + CPUHighPercent = 50.0 // on mains, sustained at or above this boosts to ProfileUnderLoad + CPULowPercent = 20.0 // and sustained at or below this goes back to ProfileOnAC + + SampleEvery = 10 * time.Second // CPU is sampled only on mains; on battery nothing is sampled + SustainSamples = 3 // 30 s above CPUHighPercent to boost + RelaxSamples = 3 // 30 s below CPULowPercent to relax + + // SafetyRecheck is how often the power source is read when no event has said it changed: the + // kernel's event is the trigger, and this only covers one lost across a suspend. + SafetyRecheck = 5 * time.Minute + + // DefaultHold is how long a profile chosen through the profile tool is kept before the switcher + // may move it again. A change of power source ends a hold at once. + DefaultHold = 60 * time.Minute + + // ChargeLimitPercent is the battery charge limit the module asserts through asusd at start. + ChargeLimitPercent = 80 +) + +// Policy is the switcher's memory of recent load: how many samples in a row were above the upper line +// or below the lower one, and whether it is boosted. +type Policy struct { + Boosted bool `json:"boosted"` + Above int `json:"samples_above"` + Below int `json:"samples_below"` + Recent []float64 `json:"recent_cpu_percent"` + BoostedSince time.Time `json:"boosted_since,omitempty"` +} + +// Observe takes one CPU sample (busy percent since the previous one) taken on mains. +func (p *Policy) Observe(cpu float64, at time.Time) { + p.Recent = append(p.Recent, round1(cpu)) + if len(p.Recent) > 6 { + p.Recent = p.Recent[len(p.Recent)-6:] + } + switch { + case cpu >= CPUHighPercent: + p.Above++ + p.Below = 0 + if !p.Boosted && p.Above >= SustainSamples { + p.Boosted = true + p.BoostedSince = at + } + case cpu <= CPULowPercent: + p.Below++ + p.Above = 0 + if p.Boosted && p.Below >= RelaxSamples { + p.Boosted = false + p.BoostedSince = time.Time{} + } + default: + // Between the lines: no direction is sustained, and the profile stays where it is. + p.Above, p.Below = 0, 0 + } +} + +// Reset forgets the load, for a change of power source. +func (p *Policy) Reset() { *p = Policy{} } + +// Decision is what the switcher would choose, and why. +type Decision struct { + Profile string `json:"profile"` + Reason string `json:"reason"` +} + +// Decide is the policy: battery → ProfileOnBattery; mains → ProfileOnAC, or ProfileUnderLoad while +// boosted. +func (p *Policy) Decide(src Source) Decision { + if !src.OnAC { + return Decision{ProfileOnBattery, "on battery (" + src.Reason + ")"} + } + if p.Boosted { + return Decision{ProfileUnderLoad, fmt.Sprintf("on mains (%s) and CPU load sustained at or above %s%% for %d samples of %s", + src.Reason, strconv.FormatFloat(CPUHighPercent, 'f', -1, 64), SustainSamples, SampleEvery)} + } + return Decision{ProfileOnAC, fmt.Sprintf("on mains (%s), and CPU load not sustained at or above %s%%", + src.Reason, strconv.FormatFloat(CPUHighPercent, 'f', -1, 64))} +} + +// CPUTimes is the first line of /proc/stat: total and idle jiffies (iowait counted as idle). +type CPUTimes struct{ Total, Idle uint64 } + +// ParseProcStat reads the aggregate cpu line of /proc/stat. +func ParseProcStat(text string) (CPUTimes, error) { + line := strings.SplitN(text, "\n", 2)[0] + f := strings.Fields(line) + if len(f) < 6 || f[0] != "cpu" { + return CPUTimes{}, fmt.Errorf("/proc/stat does not start with the cpu line") + } + var t CPUTimes + for i, s := range f[1:] { + if i >= 8 { // user nice system idle iowait irq softirq steal; guest is already in user + break + } + n, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return CPUTimes{}, fmt.Errorf("/proc/stat: %v", err) + } + t.Total += n + if i == 3 || i == 4 { + t.Idle += n + } + } + return t, nil +} + +// Busy is the percentage of time not idle between two readings. +func Busy(before, after CPUTimes) (float64, bool) { + if after.Total <= before.Total { + return 0, false + } + total := float64(after.Total - before.Total) + idle := float64(after.Idle - before.Idle) + return (total - idle) / total * 100, true +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/power.go b/modules/asus-zephyrus-g14/cmd/zephyrus/power.go new file mode 100644 index 0000000..9b0ea3f --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/power.go @@ -0,0 +1,180 @@ +package main + +import ( + "path" + "sort" + "strings" +) + +// Supply is one entry of /sys/class/power_supply as the kernel reports it. +type Supply struct { + Name string `json:"name"` + Type string `json:"type"` + Scope string `json:"scope,omitempty"` + Status string `json:"status,omitempty"` + Online *bool `json:"online,omitempty"` +} + +// Supplies is every power supply the kernel knows, sorted by name. +func (m *Machine) Supplies() []Supply { + var out []Supply + for _, dir := range m.glob("/sys/class/power_supply/*") { + s := Supply{ + Name: path.Base(dir), + Type: m.read(dir + "/type"), + Scope: m.read(dir + "/scope"), + Status: m.read(dir + "/status"), + } + if v, ok := m.readInt(dir + "/online"); ok { + on := v == 1 + s.Online = &on + } + out = append(out, s) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// system is a supply that powers this machine. A mouse's or a headset's battery reports scope +// Device, and it says nothing about whether the laptop is on mains. +func (s Supply) system() bool { return !strings.EqualFold(s.Scope, "Device") } + +// Source is where the machine draws its power from, and why that was concluded. +type Source struct { + OnAC bool `json:"on_ac"` + Source string `json:"source"` + Reason string `json:"reason"` +} + +// PowerSource decides mains or battery. +// +// **A battery that says it is discharging wins over any adapter that says it is online.** The +// predecessor's script took any `online` file reading 1 as mains, and a USB-C port reports `online` +// for things that do not power the machine. The battery's own status is the one fact that cannot be +// misread: it discharges exactly when nothing outside is carrying the load. Only when no battery says +// so are the adapters asked, and a machine with no system battery at all is on mains. +func PowerSource(supplies []Supply) Source { + batteries := 0 + for _, s := range supplies { + if s.Type == "Battery" && s.system() { + batteries++ + if strings.EqualFold(s.Status, "Discharging") { + return Source{OnAC: false, Source: "battery", Reason: s.Name + " is discharging"} + } + } + } + for _, s := range supplies { + if (s.Type == "Mains" || strings.HasPrefix(s.Type, "USB")) && s.system() && s.Online != nil && *s.Online { + return Source{OnAC: true, Source: "ac", Reason: s.Name + " (" + s.Type + ") is online"} + } + } + if batteries == 0 { + return Source{OnAC: true, Source: "ac", Reason: "this machine has no system battery"} + } + return Source{OnAC: false, Source: "battery", Reason: "no mains or USB supply is online"} +} + +// Battery is what the battery tool answers. +type Battery struct { + Name string `json:"name"` + Status string `json:"status"` + ChargePercent *int64 `json:"charge_percent,omitempty"` + EnergyWh *float64 `json:"energy_wh,omitempty"` + FullWh *float64 `json:"full_wh,omitempty"` + DesignWh *float64 `json:"design_wh,omitempty"` + HealthPercent *float64 `json:"health_percent,omitempty"` + Cycles *int64 `json:"cycles"` + CyclesNote string `json:"cycles_note,omitempty"` + LimitPercent *int64 `json:"charge_limit_percent,omitempty"` + PowerW *float64 `json:"power_w,omitempty"` + HoursRemaining *float64 `json:"hours_remaining,omitempty"` + Technology string `json:"technology,omitempty"` + Model string `json:"model,omitempty"` + Manufacturer string `json:"manufacturer,omitempty"` +} + +// Batteries reads every system battery. +func (m *Machine) Batteries() []Battery { + var out []Battery + for _, s := range m.Supplies() { + if s.Type != "Battery" || !s.system() { + continue + } + out = append(out, m.battery(s)) + } + return out +} + +func (m *Machine) battery(s Supply) Battery { + dir := "/sys/class/power_supply/" + s.Name + b := Battery{ + Name: s.Name, Status: s.Status, + Technology: m.read(dir + "/technology"), + Model: m.read(dir + "/model_name"), + Manufacturer: strings.TrimSpace(m.read(dir + "/manufacturer")), + } + if v, ok := m.readInt(dir + "/capacity"); ok { + b.ChargePercent = &v + } + // Energy in Wh: energy_* (µWh) where the firmware reports it, else charge_* (µAh) times the + // design minimum voltage, which is how upower converts it too. + wh := func(energy, charge string) *float64 { + if v, ok := m.readInt(dir + "/" + energy); ok { + f := round1(float64(v) / 1e6) + return &f + } + c, okc := m.readInt(dir + "/" + charge) + volts, okv := m.readInt(dir + "/voltage_min_design") + if okc && okv { + f := round1(float64(c) * float64(volts) / 1e12) + return &f + } + return nil + } + b.EnergyWh = wh("energy_now", "charge_now") + b.FullWh = wh("energy_full", "charge_full") + b.DesignWh = wh("energy_full_design", "charge_full_design") + if b.FullWh != nil && b.DesignWh != nil && *b.DesignWh > 0 { + h := round1(*b.FullWh / *b.DesignWh * 100) + b.HealthPercent = &h + } + if v, ok := m.readInt(dir + "/cycle_count"); ok && v > 0 { + b.Cycles = &v + } else { + b.CyclesNote = "the firmware does not report a cycle count (it reads 0)" + } + if v, ok := m.readInt(dir + "/charge_control_end_threshold"); ok { + b.LimitPercent = &v + } + if w := m.batteryWatts(dir); w != nil { + b.PowerW = w + if strings.EqualFold(s.Status, "Discharging") && b.EnergyWh != nil && *w > 0.5 { + h := round1(*b.EnergyWh / *w) + b.HoursRemaining = &h + } + } + return b +} + +// batteryWatts is how much the battery is giving or taking, in watts, unsigned: power_now where the +// firmware reports it, else current times voltage. +func (m *Machine) batteryWatts(dir string) *float64 { + if v, ok := m.readInt(dir + "/power_now"); ok { + f := round1(abs(float64(v)) / 1e6) + return &f + } + i, oki := m.readInt(dir + "/current_now") + u, oku := m.readInt(dir + "/voltage_now") + if oki && oku { + f := round1(abs(float64(i)) * float64(u) / 1e12) + return &f + } + return nil +} + +func abs(f float64) float64 { + if f < 0 { + return -f + } + return f +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/power_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/power_test.go new file mode 100644 index 0000000..3c5a1ff --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/power_test.go @@ -0,0 +1,73 @@ +package main + +import "testing" + +func on(b bool) *bool { return &b } + +func TestADischargingBatteryWinsOverAnAdapterThatSaysOnline(t *testing.T) { + got := PowerSource([]Supply{ + {Name: "BAT1", Type: "Battery", Status: "Discharging"}, + {Name: "ucsi-source-psy-USBC000:001", Type: "USB", Scope: "System", Online: on(true)}, + }) + if got.OnAC { + t.Fatalf("a USB-C port reporting online while the battery discharges was read as mains: %+v", got) + } +} + +func TestMainsOnlineIsAC(t *testing.T) { + got := PowerSource([]Supply{ + {Name: "ACAD", Type: "Mains", Online: on(true)}, + {Name: "BAT1", Type: "Battery", Status: "Not charging"}, + }) + if !got.OnAC || got.Reason != "ACAD (Mains) is online" { + t.Fatalf("%+v", got) + } +} + +func TestAPeripheralsBatteryDecidesNothing(t *testing.T) { + got := PowerSource([]Supply{ + {Name: "hidpp_battery_0", Type: "Battery", Scope: "Device", Status: "Discharging"}, + {Name: "ACAD", Type: "Mains", Online: on(true)}, + {Name: "BAT1", Type: "Battery", Status: "Charging"}, + }) + if !got.OnAC { + t.Fatalf("a mouse's discharging battery put the laptop on battery: %+v", got) + } +} + +func TestNoSupplyOnlineWithABatteryIsBatteryAndNoBatteryIsMains(t *testing.T) { + if got := PowerSource([]Supply{{Name: "ACAD", Type: "Mains", Online: on(false)}, {Name: "BAT1", Type: "Battery", Status: "Unknown"}}); got.OnAC { + t.Fatalf("%+v", got) + } + if got := PowerSource(nil); !got.OnAC { + t.Fatalf("a machine with no battery is on mains: %+v", got) + } +} + +func TestTheBatteryIsReadInWattHoursFromChargeAndHealthAgainstDesign(t *testing.T) { + f := newFake(t) + // The laptop's own battery, as measured: charge_* in µAh, no energy_* and no power_now. + f.supply("BAT1", map[string]string{ + "type": "Battery", "status": "Discharging", "capacity": "80", + "charge_now": "3073000", "charge_full": "3865000", "charge_full_design": "4580000", + "voltage_min_design": "15939000", "current_now": "1000000", "voltage_now": "16000000", + "cycle_count": "0", "charge_control_end_threshold": "80", "manufacturer": "ASUS ", + }) + bs := f.machine().Batteries() + if len(bs) != 1 { + t.Fatalf("%+v", bs) + } + b := bs[0] + if *b.EnergyWh != 49 || *b.FullWh != 61.6 || *b.DesignWh != 73 || *b.HealthPercent != 84.4 { + t.Fatalf("energy %v full %v design %v health %v", *b.EnergyWh, *b.FullWh, *b.DesignWh, *b.HealthPercent) + } + if b.Cycles != nil || b.CyclesNote == "" { + t.Fatal("a cycle count of 0 is the firmware not reporting one, and said so") + } + if *b.LimitPercent != 80 || *b.PowerW != 16 || b.HoursRemaining == nil || *b.HoursRemaining != 3.1 { + t.Fatalf("limit %v power %v hours %v", *b.LimitPercent, *b.PowerW, b.HoursRemaining) + } + if b.Manufacturer != "ASUS" { + t.Fatalf("manufacturer %q", b.Manufacturer) + } +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/switcher.go b/modules/asus-zephyrus-g14/cmd/zephyrus/switcher.go new file mode 100644 index 0000000..3e570e7 --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/switcher.go @@ -0,0 +1,303 @@ +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 +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/switcher_test.go b/modules/asus-zephyrus-g14/cmd/zephyrus/switcher_test.go new file mode 100644 index 0000000..6506426 --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/switcher_test.go @@ -0,0 +1,190 @@ +package main + +import ( + "context" + "errors" + "strconv" + "testing" + "time" +) + +func TestTheLoadMustBeSustainedToBoostAndToRelaxAndBetweenTheLinesNothingMoves(t *testing.T) { + var p Policy + at := time.Now() + mains := Source{OnAC: true, Reason: "ACAD (Mains) is online"} + for i := 0; i < SustainSamples-1; i++ { + p.Observe(90, at) + } + if p.Decide(mains).Profile != ProfileOnAC { + t.Fatal("boosted before the load was sustained") + } + p.Observe(35, at) // between the lines breaks the run + p.Observe(90, at) + if p.Boosted { + t.Fatal("a broken run still counted") + } + for i := 0; i < SustainSamples; i++ { + p.Observe(CPUHighPercent, at) + } + if d := p.Decide(mains); d.Profile != ProfileUnderLoad { + t.Fatalf("%+v", d) + } + p.Observe(35, at) + if !p.Boosted { + t.Fatal("load between the lines relaxed the boost") + } + for i := 0; i < RelaxSamples; i++ { + p.Observe(5, at) + } + if p.Decide(mains).Profile != ProfileOnAC { + t.Fatal("did not relax after a sustained low") + } + p.Boosted = true + if d := p.Decide(Source{OnAC: false, Reason: "BAT1 is discharging"}); d.Profile != ProfileOnBattery { + t.Fatalf("battery: %+v", d) + } +} + +func TestIOWaitIsIdle(t *testing.T) { + a, err := ParseProcStat("cpu 100 0 100 700 100 0 0 0 0 0\ncpu0 1 2 3\n") + if err != nil { + t.Fatal(err) + } + b, _ := ParseProcStat("cpu 150 0 150 700 200 0 0 0 0 0\n") + busy, ok := Busy(a, b) + if !ok || busy != 50 { + t.Fatalf("%v %v", busy, ok) + } + if _, ok := Busy(b, b); ok { + t.Fatal("no time passed and a load was answered") + } + if _, err := ParseProcStat("intr 1 2"); err == nil { + t.Fatal("a file without the cpu line was read") + } +} + +func switcherOn(t *testing.T) (*fake, *Switcher, *[]map[string]any) { + f := newFake(t) + f.onMains() + f.file("/proc/stat", "cpu 0 0 0 0 0 0 0 0\n") + var emitted []map[string]any + sw := NewSwitcher(f.machine(), func(_ string, body any) error { + emitted = append(emitted, body.(map[string]any)) + return nil + }) + return f, sw, &emitted +} + +func TestStartingIsNotAReasonToSwitch(t *testing.T) { + f, sw, emitted := switcherOn(t) + sw.Evaluate(context.Background(), false) + if calls := f.callsLike("asusctl profile set"); len(calls) != 0 || len(*emitted) != 0 { + t.Fatalf("the first decision acted: %v %v", calls, *emitted) + } +} + +func TestAChangeOfPowerSourceSwitchesOnceAndPublishesIt(t *testing.T) { + f, sw, emitted := switcherOn(t) + ctx := context.Background() + sw.Evaluate(ctx, false) + f.onBattery() + sw.Evaluate(ctx, false) + sw.Evaluate(ctx, true) // nothing changed: nothing done, and on battery nothing sampled + if calls := f.callsLike("asusctl profile set"); len(calls) != 1 || calls[0] != "asusctl profile set Quiet" { + t.Fatalf("%v", calls) + } + if len(*emitted) != 1 || (*emitted)[0]["profile"] != "Quiet" || (*emitted)[0]["from"] != "Balanced" { + t.Fatalf("%v", *emitted) + } + f.onMains() + sw.Evaluate(ctx, false) + if calls := f.callsLike("asusctl profile set"); len(calls) != 2 || calls[1] != "asusctl profile set Balanced" { + t.Fatalf("%v", calls) + } +} + +func TestSustainedLoadOnMainsBoostsFromSamples(t *testing.T) { + f, sw, _ := switcherOn(t) + ctx := context.Background() + sw.Evaluate(ctx, false) + var user int + for i := 1; i <= SustainSamples; i++ { + user += 90 + f.file("/proc/stat", "cpu "+itoa(user)+" 0 0 "+itoa(i*10)+" 0 0 0 0\n") + sw.Evaluate(ctx, true) + } + if !f.called("asusctl profile set Performance") { + t.Fatalf("%v", f.calls) + } +} + +func TestAHoldKeepsTheProfileUntilItEndsAndAFailureIsTriedAgain(t *testing.T) { + f, sw, _ := switcherOn(t) + ctx := context.Background() + now := time.Now() + sw.now = func() time.Time { return now } + sw.Evaluate(ctx, false) + f.onBattery() + sw.Evaluate(ctx, false) // source change ends any hold; switches to Quiet + sw.Hold("Performance", time.Hour) + f.fails["asusctl profile set Balanced"] = errors.New("asusd is restarting") + f.onMains() + sw.Evaluate(ctx, false) // a change of source: the hold ends, the switch is attempted and fails + if r := sw.Report(); r.LastError == "" || r.Held != "" { + t.Fatalf("%+v", r) + } + delete(f.fails, "asusctl profile set Balanced") + sw.Evaluate(ctx, false) + if r := sw.Report(); r.LastApplied != "Balanced" || r.LastError != "" { + t.Fatalf("not tried again: %+v", r) + } + // A hold on the same source keeps a new decision from acting until it ends. + sw.Hold("Quiet", time.Hour) + sw.policy.Boosted = true + sw.Evaluate(ctx, false) + if f.called("asusctl profile set Performance") { + t.Fatal("switched during a hold") + } + now = now.Add(2 * time.Hour) + sw.Evaluate(ctx, false) + if !f.called("asusctl profile set Performance") { + t.Fatal("did not act once the hold ended") + } +} + +func TestAsusdsSettingsAreSetOnlyWhereTheyDiffer(t *testing.T) { + f := newFake(t) + f.answers["asusctl battery info"] = "Current battery charge limit: 100%\n" + f.answers["asusctl profile get"] = "Active profile: Balanced\nAC profile Performance\nBattery profile Quiet\n" + sw := NewSwitcher(f.machine(), nil) + sw.AssertVendorSettings(context.Background()) + if !f.called("asusctl battery limit 80") || !f.called("asusctl profile set -a Balanced") || f.called("asusctl profile set -b Quiet") { + t.Fatalf("%v", f.calls) + } +} + +func TestTheSwitcherDoesNotActOnAnotherModel(t *testing.T) { + f := newFake(t) + f.file("/sys/class/dmi/id/product_family", "ROG Strix\n") + sw := NewSwitcher(f.machine(), nil) + done := make(chan struct{}) + go func() { sw.Run(context.Background()); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("the switcher ran on another model") + } + if r := sw.Report(); r.Running || r.Disabled == "" || len(f.calls) != 0 { + t.Fatalf("%+v %v", r, f.calls) + } +} + +func TestOnlyPowerSupplyUeventsWake(t *testing.T) { + yes := []byte("change@/devices/LNXSYSTM:00/ACPI0003:00/power_supply/ACAD\x00ACTION=change\x00SUBSYSTEM=power_supply\x00POWER_SUPPLY_ONLINE=0\x00") + no := []byte("change@/devices/virtual/net/wlan0\x00ACTION=change\x00SUBSYSTEM=net\x00") + if !powerSupplyEvent(yes) || powerSupplyEvent(no) { + t.Fatal("the uevent filter") + } +} + +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/thermals.go b/modules/asus-zephyrus-g14/cmd/zephyrus/thermals.go new file mode 100644 index 0000000..7c4b8ef --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/thermals.go @@ -0,0 +1,168 @@ +package main + +import ( + "context" + "path" + "sort" + "strconv" + "strings" +) + +// Sensor is one temperature, fan or power reading from hwmon. +type Sensor struct { + Chip string `json:"chip"` + Label string `json:"label"` + Value float64 `json:"value"` +} + +// DGPU is the discrete GPU as the PCI bus and its driver see it. +type DGPU struct { + Address string `json:"pci_address"` + Runtime string `json:"runtime_status"` + Name string `json:"name,omitempty"` + TempC *float64 `json:"temp_c,omitempty"` + PowerW *float64 `json:"power_w,omitempty"` + PState string `json:"pstate,omitempty"` + Note string `json:"note,omitempty"` +} + +// hwmon reads every hwmon reading of one kind: "temp" (°C), "fan" (RPM) or "power" (W). +func (m *Machine) hwmon(kind string) []Sensor { + var out []Sensor + for _, dir := range m.glob("/sys/class/hwmon/hwmon*") { + chip := m.read(dir + "/name") + inputs := m.glob(dir + "/" + kind + "*_input") + if kind == "power" { + inputs = append(inputs, m.glob(dir+"/power*_average")...) + } + for _, in := range inputs { + v, ok := m.readInt(in) + if !ok { + continue + } + base := path.Base(in) + stem := base[:strings.LastIndex(base, "_")] + label := m.read(dir + "/" + stem + "_label") + if label == "" { + label = base + } else if strings.HasSuffix(base, "_average") { + label += " (average)" + } + value := float64(v) + switch kind { + case "temp": + value = round1(value / 1000) + case "power": + value = round1(value / 1e6) + } + out = append(out, Sensor{Chip: chip, Label: label, Value: value}) + } + } + sort.Slice(out, func(i, j int) bool { + if out[i].Chip != out[j].Chip { + return out[i].Chip < out[j].Chip + } + return out[i].Label < out[j].Label + }) + return out +} + +// dgpu finds the NVIDIA display controller and, only when it is already awake, asks its driver for +// its temperature and draw. **Asking wakes it**: nvidia-smi brings a suspended GPU out of D3, which +// is the power a reading of power draw should not cost. +func (m *Machine) dgpu(ctx context.Context) *DGPU { + for _, dir := range m.glob("/sys/bus/pci/devices/*") { + if m.read(dir+"/vendor") != "0x10de" || !strings.HasPrefix(m.read(dir+"/class"), "0x03") { + continue + } + g := &DGPU{Address: path.Base(dir), Runtime: m.read(dir + "/power/runtime_status")} + if g.Runtime != "active" { + g.Note = "the discrete GPU is " + g.Runtime + "; not woken to be read" + return g + } + out, err := m.Run(ctx, "nvidia-smi", "--query-gpu=name,temperature.gpu,power.draw,pstate", "--format=csv,noheader,nounits") + if err != nil { + g.Note = "nvidia-smi: " + err.Error() + return g + } + f := strings.Split(strings.TrimSpace(strings.SplitN(out, "\n", 2)[0]), ",") + if len(f) >= 4 { + g.Name = strings.TrimSpace(f[0]) + if t, err := strconv.ParseFloat(strings.TrimSpace(f[1]), 64); err == nil { + g.TempC = &t + } + if w, err := strconv.ParseFloat(strings.TrimSpace(f[2]), 64); err == nil { + w = round1(w) + g.PowerW = &w + } + g.PState = strings.TrimSpace(f[3]) + } + return g + } + return nil +} + +// Thermals is what the thermals tool answers. +type Thermals struct { + Temperatures []Sensor `json:"temperatures_c"` + Fans []Sensor `json:"fans_rpm"` + DGPU *DGPU `json:"dgpu,omitempty"` + Profile string `json:"platform_profile,omitempty"` + Hottest *Sensor `json:"hottest,omitempty"` +} + +func (m *Machine) Thermals(ctx context.Context) Thermals { + t := Thermals{Temperatures: m.hwmon("temp"), Fans: m.hwmon("fan"), DGPU: m.dgpu(ctx), + Profile: m.read("/sys/firmware/acpi/platform_profile")} + if t.Temperatures == nil { + t.Temperatures = []Sensor{} + } + if t.Fans == nil { + t.Fans = []Sensor{} + } + for i := range t.Temperatures { + if t.Hottest == nil || t.Temperatures[i].Value > t.Hottest.Value { + h := t.Temperatures[i] + t.Hottest = &h + } + } + return t +} + +// PowerDraw is what the power-draw tool answers. +type PowerDraw struct { + Source Source `json:"source"` + BatteryW *float64 `json:"battery_w,omitempty"` + BatteryFlow string `json:"battery_flow,omitempty"` + CPUPackageW *float64 `json:"apu_package_w,omitempty"` + DGPU *DGPU `json:"dgpu,omitempty"` + Note string `json:"note"` +} + +func (m *Machine) PowerDraw(ctx context.Context) PowerDraw { + p := PowerDraw{Source: PowerSource(m.Supplies()), DGPU: m.dgpu(ctx), + Note: "on battery, battery_w is what the whole machine draws; on mains it is only what the battery takes or gives"} + for _, b := range m.Batteries() { + if b.PowerW != nil { + w := *b.PowerW + p.BatteryW = &w + switch strings.ToLower(b.Status) { + case "discharging": + p.BatteryFlow = "discharging" + case "charging": + p.BatteryFlow = "charging" + default: + p.BatteryFlow = strings.ToLower(b.Status) + } + break + } + } + // The integrated GPU's hwmon reports the whole APU's package power (PPT) on this model. + for _, s := range m.hwmon("power") { + if s.Chip == "amdgpu" && s.Label == "PPT" { + w := s.Value + p.CPUPackageW = &w + } + } + return p +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/tools.go b/modules/asus-zephyrus-g14/cmd/zephyrus/tools.go new file mode 100644 index 0000000..80c65c2 --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/tools.go @@ -0,0 +1,308 @@ +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 +} diff --git a/modules/asus-zephyrus-g14/cmd/zephyrus/uevent.go b/modules/asus-zephyrus-g14/cmd/zephyrus/uevent.go new file mode 100644 index 0000000..22a48fd --- /dev/null +++ b/modules/asus-zephyrus-g14/cmd/zephyrus/uevent.go @@ -0,0 +1,72 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "syscall" +) + +// The kernel announces every change of a power supply — an adapter plugged or pulled, a battery +// starting or stopping to discharge — as a uevent on a netlink socket that any account may listen +// on. That is the event the switcher reacts to: no daemon, no bus client, no polling. +// +// upower re-announces the same changes on the system bus, and listening there would need a D-Bus +// client in the bundle; udev's re-broadcast (netlink group 2) carries a libudev header. The kernel's +// own group (1) is the source both of them read. + +// powerSupplyEvent says whether a uevent is about a power supply. +func powerSupplyEvent(msg []byte) bool { + for _, field := range bytes.Split(msg, []byte{0}) { + if bytes.Equal(field, []byte("SUBSYSTEM=power_supply")) { + return true + } + } + return false +} + +// listenPowerSupply opens the kernel's uevent socket and sends on the channel for each power-supply +// event, never blocking: a burst of events is one wake-up. It stops when ctx ends. +func listenPowerSupply(ctx context.Context) (<-chan struct{}, error) { + fd, err := syscall.Socket(syscall.AF_NETLINK, syscall.SOCK_RAW|syscall.SOCK_CLOEXEC, syscall.NETLINK_KOBJECT_UEVENT) + if err != nil { + return nil, fmt.Errorf("opening the kernel's uevent socket: %w", err) + } + if err := syscall.Bind(fd, &syscall.SockaddrNetlink{Family: syscall.AF_NETLINK, Groups: 1}); err != nil { + syscall.Close(fd) + return nil, fmt.Errorf("joining the kernel's uevent group: %w", err) + } + events := make(chan struct{}, 1) + go func() { + <-ctx.Done() + syscall.Close(fd) + }() + go func() { + defer close(events) + buf := make([]byte, 64*1024) + for { + n, _, err := syscall.Recvfrom(fd, buf, 0) + if err != nil { + if err == syscall.EINTR || err == syscall.ENOBUFS { + // ENOBUFS: events were dropped. Treat it as one, since a dropped one may have + // been the adapter. + if err == syscall.ENOBUFS { + select { + case events <- struct{}{}: + default: + } + } + continue + } + return + } + if powerSupplyEvent(buf[:n]) { + select { + case events <- struct{}{}: + default: + } + } + } + }() + return events, nil +} diff --git a/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight b/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight new file mode 100755 index 0000000..1a3ec31 --- /dev/null +++ b/modules/asus-zephyrus-g14/files/bin/zephyrus-backlight @@ -0,0 +1,33 @@ +#!/bin/bash +# zephyrus-backlight + | - | PERCENT — step or set the internal panel's backlight, never below 1 %. +# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# The panel is the backlight beneath the eDP connector, not a name: in hybrid mode this model also +# registers the discrete GPU's backlight (nvidia_0), which moves nothing. Writable by the video group +# through the module's udev rule, so the vendor-key trigger needs no root. +set -u +STEP=5 +dev="" +for d in /sys/class/backlight/*; do + [ -e "$d" ] || continue + case "$(readlink -f "$d")" in *-eDP-*) dev=$d; break ;; esac +done +if [ -z "$dev" ]; then + for d in /sys/class/backlight/*; do [ -e "$d" ] && { dev=$d; break; }; done +fi +[ -n "$dev" ] || { echo "zephyrus-backlight: no backlight" >&2; exit 1; } +cur=$(cat "$dev/brightness") +max=$(cat "$dev/max_brightness") +pct=$(( cur * 100 / max )) +case "${1:-}" in + +|up|Up) pct=$(( pct + STEP )) ;; + -|down|Down) pct=$(( pct - STEP )) ;; + ''|*[!0-9]*) echo "usage: zephyrus-backlight + | - | PERCENT" >&2; exit 2 ;; + *) pct=$1 ;; +esac +(( pct < 1 )) && pct=1 +(( pct > 100 )) && pct=100 +new=$(( max * pct / 100 )) +(( new < 1 )) && new=1 +printf '%s' "$new" >"$dev/brightness" || exit 1 +exec "$(dirname "$0")/zephyrus-notify" 5555 "Brightness: ${pct}%" diff --git a/modules/asus-zephyrus-g14/files/bin/zephyrus-notify b/modules/asus-zephyrus-g14/files/bin/zephyrus-notify new file mode 100755 index 0000000..1e8273f --- /dev/null +++ b/modules/asus-zephyrus-g14/files/bin/zephyrus-notify @@ -0,0 +1,12 @@ +#!/bin/bash +# zephyrus-notify ID SUMMARY — a short desktop notification that replaces the previous one with the +# same ID, through the session's notification service on its bus. busctl is the service manager's +# own client, so nothing is installed for it. Shipped by the mesh's asus-zephyrus-g14 module. +set -u +id=${1:-0} +summary=${2:-} +uid=$(id -u) +DBUS_SESSION_BUS_ADDRESS="unix:path=/run/user/${uid}/bus" \ + busctl --user call org.freedesktop.Notifications /org/freedesktop/Notifications \ + org.freedesktop.Notifications Notify susssasa{sv}i \ + asus-zephyrus-g14 "$id" "" "$summary" "" 0 1 urgency y 0 1500 >/dev/null 2>&1 || true diff --git a/modules/asus-zephyrus-g14/files/bin/zephyrus-session b/modules/asus-zephyrus-g14/files/bin/zephyrus-session new file mode 100755 index 0000000..b74de75 --- /dev/null +++ b/modules/asus-zephyrus-g14/files/bin/zephyrus-session @@ -0,0 +1,38 @@ +#!/bin/bash +# zephyrus-session COMMAND [ARG...] — run a command in the operator's graphical session from outside +# it: from a vendor-key trigger, which triggerhappy runs as the operator's account but with none of +# the session's environment. Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# What it replaces: the predecessor's `as-user`, which triggerhappy ran as root and which `su`-ed to +# a named person with a hard-coded user id and display, and sourced a file of secrets on the way. +# Here the account is whoever runs it, the bus is that account's, and the display is the one the +# account's own session uses. Nothing is sourced. +set -u +uid=$(id -u) +export XDG_RUNTIME_DIR="/run/user/${uid}" +export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus" +home=$(getent passwd "$uid" | cut -d: -f6) +[ -n "$home" ] && export HOME="$home" +if [ -z "${DISPLAY:-}" ]; then + # The login manager may not record the display with logind; any process of this account that + # has one says which it is. + for s in $(loginctl list-sessions --no-legend 2>/dev/null | awk -v u="$uid" '$2 == u { print $1 }'); do + d=$(loginctl show-session "$s" -p Display --value 2>/dev/null) + if [ -n "$d" ]; then export DISPLAY="$d"; break; fi + done +fi +if [ -z "${DISPLAY:-}" ]; then + for pid in $(pgrep -u "$uid" 2>/dev/null); do + env=$(tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null) || continue + d=$(printf '%s\n' "$env" | sed -n 's/^DISPLAY=//p' | head -n1) + if [ -n "$d" ]; then + export DISPLAY="$d" + a=$(printf '%s\n' "$env" | sed -n 's/^XAUTHORITY=//p' | head -n1) + [ -n "$a" ] && export XAUTHORITY="$a" + break + fi + done +fi +: "${XAUTHORITY:=${HOME}/.Xauthority}" +export XAUTHORITY +exec "$@" diff --git a/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad b/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad new file mode 100755 index 0000000..637be36 --- /dev/null +++ b/modules/asus-zephyrus-g14/files/bin/zephyrus-touchpad @@ -0,0 +1,29 @@ +#!/bin/bash +# zephyrus-touchpad reset | toggle — apply the touchpad's settings again, or switch it on or off. +# Shipped by the mesh's asus-zephyrus-g14 module; edit the catalogue. +# +# The settings themselves are an X input class (/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf), +# which X applies every time the device appears — after a resume too, which is what the predecessor's +# sleep hook existed for. This is the manual form, bound to the touchpad key. +set -u +here=$(dirname "$0") +name=$("$here/zephyrus-session" xinput list --name-only 2>/dev/null | grep -m1 -i 'touchpad') +[ -n "$name" ] || { echo "zephyrus-touchpad: no touchpad in this session" >&2; exit 1; } +x() { "$here/zephyrus-session" xinput "$@"; } +case "${1:-reset}" in + reset) + x set-prop "$name" "libinput Tapping Enabled" 1 + x set-prop "$name" "libinput Natural Scrolling Enabled" 1 + x set-prop "$name" "libinput Accel Speed" 0.15 + x enable "$name" + "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: reset" + ;; + toggle) + if x list-props "$name" | grep -q 'Device Enabled ([0-9]*):[[:space:]]*1'; then + x disable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: off" + else + x enable "$name"; "$here/zephyrus-session" "$here/zephyrus-notify" 7779 "Touchpad: on" + fi + ;; + *) echo "usage: zephyrus-touchpad reset | toggle" >&2; exit 2 ;; +esac diff --git a/modules/asus-zephyrus-g14/go.mod b/modules/asus-zephyrus-g14/go.mod new file mode 100644 index 0000000..1b25a03 --- /dev/null +++ b/modules/asus-zephyrus-g14/go.mod @@ -0,0 +1,5 @@ +module asuszephyrusg14 + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/asus-zephyrus-g14/go.sum b/modules/asus-zephyrus-g14/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/asus-zephyrus-g14/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/asus-zephyrus-g14/module.json b/modules/asus-zephyrus-g14/module.json new file mode 100644 index 0000000..863c95b --- /dev/null +++ b/modules/asus-zephyrus-g14/module.json @@ -0,0 +1,254 @@ +{ + "module": "asus-zephyrus-g14", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "emits": [ + "profile.switched" + ], + "tools": [ + "zephyrus_brightness", + "zephyrus_battery", + "zephyrus_charge_limit", + "zephyrus_gpu_mode", + "zephyrus_profile", + "zephyrus_thermals", + "zephyrus_power_draw", + "zephyrus_profile_policy", + "zephyrus_fan_curves", + "zephyrus_check" + ], + "resources": [ + { + "id": "asusctl", + "type": "package", + "package": "asusctl" + }, + { + "id": "playerctl", + "type": "package", + "package": "playerctl" + }, + { + "id": "xinput", + "type": "package", + "package": "xorg-xinput" + }, + { + "id": "asusd", + "type": "service", + "unit": "asusd.service", + "state": "running" + }, + { + "id": "supergfxd", + "type": "service", + "unit": "supergfxd.service", + "state": "running", + "boot": "enabled" + }, + { + "id": "scripts", + "type": "archive", + "path": "/usr/local/lib/asus-zephyrus-g14", + "artifact": "scripts" + }, + { + "id": "nvidia-options", + "type": "file", + "path": "/etc/modprobe.d/g14-nvidia-power.conf", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The discrete GPU's driver options on the ROG Zephyrus G14 (GA403, RTX 40 series, hybrid graphics).\n#\n# NVreg_DynamicPowerManagement=0x00 turns runtime D3 off. With it on, a change of power source sends\n# the driver an ACPI notification it fails to handle on this model (\"RmHandleDNotifierEvent: Failed to\n# handle ACPI D-Notifier event, status=0x62\"), and the GPU stops making progress until the machine is\n# powered off. Off costs a few idle watts in hybrid mode and keeps the machine up.\n#\n# NVreg_PreserveVideoMemoryAllocations=1 saves video memory across suspend, so what used the GPU still\n# works after waking. It needs nvidia-suspend, -hibernate and -resume to run around a sleep, which this\n# module's drop-ins on the sleep services ask for.\n#\n# A change here applies when the driver next loads: at the next boot.\noptions nvidia NVreg_PreserveVideoMemoryAllocations=1\noptions nvidia NVreg_DynamicPowerManagement=0x00\n" + }, + { + "id": "video-options", + "type": "file", + "path": "/etc/modprobe.d/video-brightness-switch.conf", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ACPI video driver does not change the backlight itself on the brightness keys: on this model it\n# moves the wrong one. The keys are triggerhappy's (see /etc/triggerhappy/triggers.d/asus-g14.conf).\n# Applies when the module next loads: at the next boot.\noptions video brightness_switch_enabled=0\n" + }, + { + "id": "suspend-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/systemd-suspend.service.d", + "mode": "0755" + }, + { + "id": "nvidia-on-suspend", + "type": "file", + "path": "/etc/systemd/system/systemd-suspend.service.d/asus-zephyrus-g14-nvidia.conf", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend.service nvidia-resume.service\n" + }, + { + "id": "hibernate-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/systemd-hibernate.service.d", + "mode": "0755" + }, + { + "id": "nvidia-on-hibernate", + "type": "file", + "path": "/etc/systemd/system/systemd-hibernate.service.d/asus-zephyrus-g14-nvidia.conf", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-hibernate.service nvidia-resume.service\n" + }, + { + "id": "suspend-then-hibernate-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d", + "mode": "0755" + }, + { + "id": "nvidia-on-suspend-then-hibernate", + "type": "file", + "path": "/etc/systemd/system/systemd-suspend-then-hibernate.service.d/asus-zephyrus-g14-nvidia.conf", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The NVIDIA driver's own sleep actions, asked for by the sleep itself rather than enabled as\n# links: the mesh declares files and never makes links (novox/hq ADR 0012), and the host's service\n# shape must not start these units by hand, which would put the GPU to sleep with the machine awake.\n[Unit]\nWants=nvidia-suspend-then-hibernate.service nvidia-resume.service\n" + }, + { + "id": "powerd-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/nvidia-powerd.service.d", + "mode": "0755" + }, + { + "id": "powerd-opt-in", + "type": "file", + "path": "/etc/systemd/system/nvidia-powerd.service.d/asus-zephyrus-g14.conf", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# nvidia-powerd (Dynamic Boost) was the first error in the chain that hung this model's GPU on a change\n# of power source, and asusd starts it on mains. It runs only when the kernel command line says\n# zephyrus.nvidia-powerd — an explicit opt-in, at boot.\n[Unit]\nConditionKernelCommandLine=zephyrus.nvidia-powerd\n" + }, + { + "id": "logind-drop-ins", + "type": "directory", + "path": "/etc/systemd/logind.conf.d", + "mode": "0755" + }, + { + "id": "logind-power", + "type": "file", + "path": "/etc/systemd/logind.conf.d/power.conf", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The power key and the lid suspend, on battery, on mains and docked alike.\n[Login]\nHandlePowerKey=suspend\nHandleLidSwitch=suspend\nHandleLidSwitchExternalPower=suspend\nHandleLidSwitchDocked=suspend\n" + }, + { + "id": "logind", + "type": "service", + "unit": "systemd-logind.service", + "reload-on": [ + "logind-power" + ] + }, + { + "id": "backlight-rule", + "type": "file", + "path": "/etc/udev/rules.d/90-backlight.rules", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The backlights are writable by the video group, so the brightness keys and the module's brightness tool\n# move the panel without root.\nACTION==\"add\", SUBSYSTEM==\"backlight\", RUN+=\"/usr/bin/chgrp video /sys/class/backlight/%k/brightness\", RUN+=\"/usr/bin/chmod g+w /sys/class/backlight/%k/brightness\"\n" + }, + { + "id": "udev", + "type": "service", + "unit": "systemd-udevd.service", + "reload-on": [ + "backlight-rule" + ] + }, + { + "id": "triggerhappy-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/triggerhappy.service.d", + "mode": "0755" + }, + { + "id": "triggerhappy-as-account", + "type": "file", + "path": "/etc/systemd/system/triggerhappy.service.d/asus-zephyrus-g14.conf", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# triggerhappy runs the vendor-key triggers as the operator's account, not as root: it opens the input\n# devices first and then drops to the account with its groups (input, video), so the triggers reach the\n# account's own session bus and the panel through the video group, with no su and no hard-coded user.\n[Service]\nExecStart=\nExecStart=/usr/bin/thd --triggers /etc/triggerhappy/triggers.d/ --socket /run/thd.socket --user ${machine:account} --deviceglob /dev/input/event*\n" + }, + { + "id": "vendor-keys", + "type": "file", + "path": "/etc/triggerhappy/triggers.d/asus-g14.conf", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The ROG Zephyrus G14's vendor keys, which reach no X client. Run as the operator's account (see the\n# module's drop-in on triggerhappy.service).\nKEY_PROG1\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session playerctl play-pause\nKEY_PROG3\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session playerctl previous\nKEY_PROG4\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-session playerctl next\nKEY_BRIGHTNESSDOWN\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSDOWN\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight -\nKEY_BRIGHTNESSUP\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_BRIGHTNESSUP\t2\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-backlight +\nKEY_F21\t1\t/usr/local/lib/asus-zephyrus-g14/bin/zephyrus-touchpad reset\n" + }, + { + "id": "triggerhappy", + "type": "service", + "unit": "triggerhappy.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "triggerhappy-as-account", + "vendor-keys", + "scripts" + ] + }, + { + "id": "upower-package", + "type": "package", + "package": "upower" + }, + { + "id": "upower-drop-ins", + "type": "directory", + "path": "/etc/UPower/UPower.conf.d", + "mode": "0755" + }, + { + "id": "low-battery", + "type": "file", + "path": "/etc/UPower/UPower.conf.d/50-asus-zephyrus-g14.conf", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# On low battery the machine suspends rather than powering off, at 7 % — s2idle still draws a little,\n# so it leaves headroom. A drop-in over the package's own UPower.conf, which stays the package's.\n[UPower]\nUsePercentageForPolicy=true\nPercentageLow=15.0\nPercentageCritical=10.0\nPercentageAction=7.0\nCriticalPowerAction=Suspend\nAllowRiskyCriticalPowerAction=true\n" + }, + { + "id": "upower", + "type": "service", + "unit": "upower.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "low-battery" + ] + }, + { + "id": "xorg-drop-ins", + "type": "directory", + "path": "/etc/X11/xorg.conf.d", + "mode": "0755" + }, + { + "id": "touchpad", + "type": "file", + "path": "/etc/X11/xorg.conf.d/30-asus-zephyrus-g14-touchpad.conf", + "mode": "0644", + "content": "# Managed by the mesh (module asus-zephyrus-g14). Replaced on every push; edit the catalogue instead.\n#\n# The touchpad's settings, applied by X every time the device appears — at login and after every\n# resume, when the device is initialised again. This replaces the predecessor's sleep hook, which ran\n# xinput after a resume as a named person on a guessed display.\nSection \"InputClass\"\n Identifier \"asus-zephyrus-g14 touchpad\"\n MatchIsTouchpad \"on\"\n Option \"Tapping\" \"on\"\n Option \"NaturalScrolling\" \"true\"\n Option \"AccelSpeed\" \"0.15\"\nEndSection\n" + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/zephyrus", + "binary": "zephyrus", + "loads": [ + "zephyrus" + ] + }, + { + "name": "scripts", + "kind": "archive", + "from": "files" + } + ] + } +} diff --git a/modules/memory-pressure/README.md b/modules/memory-pressure/README.md new file mode 100644 index 0000000..8c4d0c7 --- /dev/null +++ b/modules/memory-pressure/README.md @@ -0,0 +1,120 @@ +# memory-pressure + +Compressed swap in RAM, systemd-oomd, and a guard that warns before the machine kills for memory. It +can be assigned on any machine (novox/hq research 027/03, 026/05, to-be 42 phase 3). Memory pressure +is not the laptop's alone. + +## What it owns + +| | what | notes | +|---|---|---| +| package | `zram-generator` | | +| file | `/etc/systemd/zram-generator.conf` | `zram0`: `min(ram / 2, 16384)` MiB, zstd, swap priority 100. These are the laptop's values, adopted (the path too, ADR 0182). They scale with the machine: 15.3 GiB on 30 GiB of RAM, 16 GiB on the 125 GiB desktop. Read at boot, so a change applies at the next boot. Resizing a live device would mean swapping it off, which pushes what it holds back into RAM | +| file | `/etc/sysctl.d/90-memory-pressure.conf` | `vm.page-cluster = 0` (no read-ahead on swap in RAM). Swappiness is left alone on purpose: the comment says why. `systemd-sysctl` is re-run | +| file | `/etc/systemd/oomd.conf.d/memory-pressure.conf` | `SwapUsedLimit=90%`, `DefaultMemoryPressureLimit=60%`, `DefaultMemoryPressureDurationSec=20s` | +| file | `/etc/systemd/system/-.slice.d/10-oomd.conf` | `ManagedOOMSwap=kill` | +| file | `/etc/systemd/system/user@.service.d/10-oomd.conf` | `ManagedOOMMemoryPressure=kill`, limit 80 % | +| service | `systemd-oomd` running, enabled | restarted on any of its drop-ins, after a `daemon-reload` | + +The swap on disk is not this module's. Whether there is a swap file or a partition, and how large, is +the machine's swap layout (research 027 question 3, the `kernel` module). The two workstations differ: +the laptop has a 32 GiB swap file (priority 10) and a 16 GiB partition, and the desktop a 128 GiB +partition and no zram. This module adds the zram tier above whatever is there. + +## The long-running code: the guard (ADR 0198) + +The Go bundle serves the tools and runs the guard in the same process, launched by the node's runtime. +It replaces the predecessor's `mem-guard` user unit, which checked used RAM every 30 s and called +`notify-send`. + +- **Three signals:** RAM used ≥ 95 % (the predecessor's line, which it had raised from 90), + swap used ≥ 80 % (oomd kills at 90 %), or memory pressure (PSI `some` avg10) ≥ 40 % (oomd acts at + 60 %, or 80 % for the user manager, over 20 s). Whichever comes first warns, and the warning says + which. +- **Every 10 s**, not 30: oomd's window is 20 s, so a check every 30 s could warn after oomd had + already acted. Each check reads two files. PSI triggers would wake on pressure alone, but not on RAM + or swap filling, so one timer reads all three rather than run two mechanisms. +- **Once per episode.** After a warning the guard re-arms only when every value is below its clearing + line: 88 % RAM, 70 % swap, 10 % pressure. +- **The warning names what to close:** the three largest units by resident plus swapped memory, and + in each its largest process. +- **The desktop, from outside the session.** The runtime runs as the operator's account but outside + the graphical session, with no session bus address and no `XDG_RUNTIME_DIR` (measured on the + laptop's runtime unit). The guard names the account's own bus, `/run/user//bus`, and calls + `org.freedesktop.Notifications.Notify` with `busctl --user`. That client is the service manager's + own, so nothing is installed, and a server pulls in no libnotify. Measured on 2026-10-04: from a + clean environment as the account's uid, the notification service on that bus answered (dunst 1.13). + The account is the runtime's `MESH_OPERATOR_ACCOUNT`. A runtime running as another uid could not + authenticate on that bus, and the guard says so rather than try. +- **And always an event:** `pressure.high` (reasons, percentages, the largest units) and + `pressure.cleared` (how long it lasted) are published through the runtime, whether or not anyone is + at a desktop. A machine without one has no session bus, and `memory_guard` says + *no session bus*. + +Thresholds are constants until settings exist (issue 168). + +**Known limit.** The runtime restarts a launched bundle that exits on its next tool call, not at once. +The guard recovers from a panic and reports it, but a crashed process waits for a call. + +## Tools + +| tool | what | +|---|---| +| `memory_status` | RAM, swap and each device with its priority, zram and its ratio, PSI some/full, and the guard's verdict on them | +| `memory_top` | the largest processes by resident plus swap, with their unit; `by=unit` sums per unit, which is what systemd-oomd chooses among | +| `memory_oom_history` | what was killed, newest first, from the journal: the kernel's OOM killer, systemd-oomd, and the service manager's *killed by the OOM killer*. `since` is checked against a short grammar before it reaches `journalctl` | +| `memory_zram` | each zram device (algorithm, size, stored, compressed, RAM used, ratio, same-filled and incompressible pages), the generator's configuration, swappiness and page-cluster | +| `memory_oomd` | whether oomd runs, and what it watches (`oomctl`) | +| `memory_guard` | the guard's thresholds, last verdict, whether a warning stands, and whether the desktop can be reached | + +## Found on 2026-10-04 (read-only) + +- **On the laptop, systemd-oomd guards almost nothing a person runs.** Every desktop application runs + in the login session's scope (`session-1.scope`), because the login manager and i3 start them there + and not in per-application scopes under `user@.service`. The pressure kill on `user@.service` + therefore watches 0.5 GiB. The swap kill on `-.slice`, when it fires, would choose the largest + cgroup, which is the whole session scope: X, i3 and every application at once. `memory_top by=unit` + shows it. **The fix is the graphical session's** (phase 2): launch applications in their own + scopes, for example `systemd-run --user --scope` from the launcher, and oomd then kills one + application. This module does not widen oomd's reach onto `user-.slice`, because there it would kill + the session. +- The desktop has no zram and oomd disabled. Assigning the module there is a change of behaviour: + 16 GiB of zram at the next boot, and oomd enabled with the same caveat about the session scope. + +## When assigned to the laptop: what changes + +1. Written over found files (originals kept once): `zram-generator.conf` (same values, so nothing + until the next boot either), `-.slice.d/10-oomd.conf` and `user@.service.d/10-oomd.conf` (same + keys). +2. New: `sysctl.d/90-memory-pressure.conf` (the same `vm.page-cluster = 0` already in force) and + `oomd.conf.d/memory-pressure.conf` (the same values as the predecessor's file beside it). +3. `systemd-sysctl` is re-run (the values are unchanged), and `daemon-reload` and `systemd-oomd` are + restarted. +4. The node runtime restarts with the bundle, and the guard starts. **Until `mem-guard` is stopped, + two notifiers run.** + +## Predecessor files this module makes redundant — the operator removes them once (ADR 0182) + +On the laptop: + +1. `systemctl --user disable --now mem-guard.service`, then delete + `~/.config/systemd/user/mem-guard.service` and `~/scripts/mem-guard.sh`. +2. `/etc/systemd/oomd.conf.d/g14-oomd.conf`: the same values as the module's drop-in. +3. `/etc/sysctl.d/90-g14-zram.conf`: the same key as the module's file. + +The desktop had none of these. + +## Tests + +`go test ./...`. They read a tree standing in for `/proc` and `/sys`, using the laptop's own swaps, +zram statistics and PSI lines. They cover: + +- the snapshot; +- the guard's three lines, its once-per-episode latch and its clearing, with the largest named; +- an unreachable desktop still publishing the event; +- the notification being one `busctl` call on the account's bus; +- grouping by unit; +- the journal's three kinds of kill, with non-UTF-8 messages skipped; +- `since` refused when it is an option; +- *no match* read as no kills; +- the manifest: tools listed equal tools served, events, no machine named, triggers exist. diff --git a/modules/memory-pressure/cmd/memory-pressure/guard.go b/modules/memory-pressure/cmd/memory-pressure/guard.go new file mode 100644 index 0000000..3ad6d34 --- /dev/null +++ b/modules/memory-pressure/cmd/memory-pressure/guard.go @@ -0,0 +1,286 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/user" + "strconv" + "strings" + "sync" + "time" +) + +// The module's long-running code (novox/hq ADR 0198): the guard that warns before the machine kills +// something for memory. It replaces the predecessor's `mem-guard`, a user unit that checked used RAM +// every thirty seconds and called notify-send. +// +// What changed, and why: +// +// - **Three signals, not one.** Used RAM alone misses the two things that decide whether a kill is +// coming: swap filling (systemd-oomd kills at SwapUsedLimit) and stall pressure (it kills a unit +// whose pressure stays over its limit for twenty seconds). The guard warns on whichever comes +// first, and says which. +// - **Ten seconds, not thirty.** oomd's own window is twenty seconds; a check every thirty can +// notice only after oomd has acted. Reading two files every ten seconds costs nothing measurable. +// The kernel's PSI triggers would wake on pressure alone, but not on RAM or swap filling, so the +// guard reads all three on one timer rather than run two mechanisms. +// - **The notification names what to close.** It lists the largest units, not only a percentage. +// - **An event as well as a notification.** `pressure.high` and `pressure.cleared` reach the mesh +// whether or not anyone is at the desktop — a server has no desktop at all. +// - **No session needed.** The runtime runs as the operator's account but outside the graphical +// session, so it has no session bus address. The guard names the account's own bus, +// /run/user//bus, and speaks to the notification service with busctl, the service manager's +// client: nothing is installed for it, and a machine without a desktop simply has no such bus. + +// Thresholds, as constants until settings exist (novox/hq issue 168). Used RAM is the predecessor's, +// raised by it from 90 to 95 after the lower line fired during ordinary work. +const ( + WarnUsedPercent = 95.0 + ClearUsedPercent = 88.0 + WarnSwapPercent = 80.0 // systemd-oomd's SwapUsedLimit is 90 % + ClearSwapPercent = 70.0 + WarnPressureAvg10 = 40.0 // "some" avg10; oomd acts at 60 % (80 % for the user manager) over 20 s + ClearPressureAvg10 = 10.0 + CheckEvery = 10 * time.Second + NotificationID = 9010 // replaces the previous warning rather than stacking +) + +// Verdict is what one check of the machine concluded. +type Verdict struct { + High bool `json:"high"` + Reasons []string `json:"reasons"` + Clear bool `json:"clear"` +} + +// Judge applies the thresholds to a snapshot. High is any warning line crossed; Clear is every value +// below its clearing line, which is what re-arms the guard. +func Judge(s Snapshot) Verdict { + v := Verdict{Reasons: []string{}} + clear := true + if s.UsedPercent >= WarnUsedPercent { + v.Reasons = append(v.Reasons, fmt.Sprintf("RAM %.0f%% used (warns at %.0f%%)", s.UsedPercent, WarnUsedPercent)) + } + if s.UsedPercent > ClearUsedPercent { + clear = false + } + if s.SwapTotalGiB > 0 { + if s.SwapPercent >= WarnSwapPercent { + v.Reasons = append(v.Reasons, fmt.Sprintf("swap %.0f%% used (warns at %.0f%%; systemd-oomd kills at 90%%)", s.SwapPercent, WarnSwapPercent)) + } + if s.SwapPercent > ClearSwapPercent { + clear = false + } + } + if s.PressureSome != nil { + if s.PressureSome.Avg10 >= WarnPressureAvg10 { + v.Reasons = append(v.Reasons, fmt.Sprintf("tasks stalled on memory %.0f%% of the last 10 s (warns at %.0f%%)", s.PressureSome.Avg10, WarnPressureAvg10)) + } + if s.PressureSome.Avg10 > ClearPressureAvg10 { + clear = false + } + } + v.High = len(v.Reasons) > 0 + v.Clear = clear + return v +} + +// Guard is the notifier's state, shared with the tools that report it. +type Guard struct { + m *Machine + now func() time.Time + emit func(string, any) error + notify func(ctx context.Context, summary, body string) error + + mu sync.Mutex + latched bool + since time.Time + last *Verdict + lastAt time.Time + notified string + err string + desktop string + stopped string +} + +func NewGuard(m *Machine, emit func(string, any) error) *Guard { + g := &Guard{m: m, now: time.Now, emit: emit} + g.notify = g.desktopNotify + return g +} + +// Check is one wake-up: read, judge, and on crossing a line warn once; on clearing every line, re-arm. +func (g *Guard) Check(ctx context.Context) { + s, err := g.m.Snapshot() + g.mu.Lock() + if err != nil { + g.err = err.Error() + g.mu.Unlock() + return + } + v := Judge(s) + now := g.now() + g.last, g.lastAt, g.err = &v, now, "" + warn := v.High && !g.latched + cleared := g.latched && v.Clear + if warn { + g.latched, g.since = true, now + } + if cleared { + g.latched = false + } + since := g.since + g.mu.Unlock() + + if warn { + largest := ByUnit(g.m.Processes(), 3) + var names []string + for _, u := range largest { + names = append(names, fmt.Sprintf("%s %.1f GiB", firstNonEmpty(u.Largest, u.Unit), (u.RSSMiB+u.SwapMiB)/1024)) + } + body := strings.Join(v.Reasons, "; ") + if len(names) > 0 { + body += ". Largest: " + strings.Join(names, ", ") + } + body += ". systemd-oomd kills the worst unit if it climbs further." + g.publish("pressure.high", map[string]any{"reasons": v.Reasons, "used_percent": s.UsedPercent, + "swap_used_percent": s.SwapPercent, "pressure_some": s.PressureSome, "largest": largest}) + err := g.notify(ctx, "Memory is running low", body) + g.mu.Lock() + if err != nil { + g.desktop = "not reached: " + err.Error() + } else { + g.desktop, g.notified = "reached", now.Format(time.RFC3339) + } + g.mu.Unlock() + } + if cleared { + g.publish("pressure.cleared", map[string]any{"used_percent": s.UsedPercent, "swap_used_percent": s.SwapPercent, + "lasted_seconds": int(now.Sub(since).Seconds())}) + } +} + +func (g *Guard) publish(eventType string, body map[string]any) { + if g.emit == nil { + return + } + if err := g.emit(eventType, body); err != nil { + fmt.Fprintf(os.Stderr, "%s not published: %v\n", eventType, err) + } +} + +// Bus is the operator's session bus socket: the account the runtime names, else the one this process +// runs as. +func Bus() (string, error) { + uid := os.Getuid() + if name := os.Getenv("MESH_OPERATOR_ACCOUNT"); name != "" { + u, err := user.Lookup(name) + if err != nil { + return "", fmt.Errorf("the operator account %s: %w", name, err) + } + if n, err := strconv.Atoi(u.Uid); err == nil { + if n != uid && uid != 0 { + return "", fmt.Errorf("this process runs as uid %d and the operator's bus belongs to uid %d", uid, n) + } + uid = n + } + } + return fmt.Sprintf("/run/user/%d/bus", uid), nil +} + +// desktopNotify sends one critical notification to the operator's session, replacing the previous one. +func (g *Guard) desktopNotify(ctx context.Context, summary, body string) error { + bus, err := Bus() + if err != nil { + return err + } + if _, err := os.Stat(bus); err != nil { + return fmt.Errorf("no session bus at %s (nobody is logged in to a desktop)", bus) + } + _, err = g.m.Run(ctx, "env", "DBUS_SESSION_BUS_ADDRESS=unix:path="+bus, + "busctl", "--user", "call", "org.freedesktop.Notifications", "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", "Notify", "susssasa{sv}i", + "memory-pressure", strconv.Itoa(NotificationID), "dialog-warning", summary, body, "0", "1", "urgency", "y", "2", "0") + return err +} + +// Run is the guard's life: a check every CheckEvery. +func (g *Guard) Run(ctx context.Context) { + defer func() { + if r := recover(); r != nil { + g.mu.Lock() + g.stopped = fmt.Sprintf("the guard stopped on a fault: %v", r) + g.mu.Unlock() + fmt.Fprintln(os.Stderr, g.stopped) + } + }() + t := time.NewTicker(CheckEvery) + defer t.Stop() + for { + g.Check(ctx) + select { + case <-ctx.Done(): + return + case <-t.C: + } + } +} + +// GuardReport is the guard as the guard tool shows it. +type GuardReport struct { + Running bool `json:"running"` + Stopped string `json:"stopped,omitempty"` + Warned bool `json:"warning_given"` + WarnedSince *time.Time `json:"warning_since,omitempty"` + LastCheck *time.Time `json:"last_check,omitempty"` + LastVerdict *Verdict `json:"last_verdict,omitempty"` + LastError string `json:"last_error,omitempty"` + Desktop string `json:"desktop"` + LastNotified string `json:"last_notified,omitempty"` + Thresholds map[string]any `json:"thresholds"` +} + +func (g *Guard) Report() GuardReport { + g.mu.Lock() + defer g.mu.Unlock() + r := GuardReport{ + Running: g.stopped == "" && !g.lastAt.IsZero(), Stopped: g.stopped, Warned: g.latched, LastCheck: when(g.lastAt), + LastVerdict: g.last, LastError: g.err, Desktop: g.desktop, LastNotified: g.notified, + Thresholds: map[string]any{ + "warn_used_percent": WarnUsedPercent, "clear_used_percent": ClearUsedPercent, + "warn_swap_percent": WarnSwapPercent, "clear_swap_percent": ClearSwapPercent, + "warn_pressure_some_avg10": WarnPressureAvg10, "clear_pressure_some_avg10": ClearPressureAvg10, + "check_every": CheckEvery.String(), "set_by": "constants until settings exist (novox/hq issue 168)", + }, + } + if g.latched { + r.WarnedSince = when(g.since) + } + if r.Desktop == "" { + if bus, err := Bus(); err != nil { + r.Desktop = "not reachable: " + err.Error() + } else if _, err := os.Stat(bus); err != nil { + r.Desktop = "no session bus at " + bus + } else { + r.Desktop = "reachable at " + bus + " (not yet used)" + } + } + return r +} + +func firstNonEmpty(ss ...string) string { + for _, s := range ss { + if s != "" { + return s + } + } + return "" +} + +// 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 +} diff --git a/modules/memory-pressure/cmd/memory-pressure/helpers_test.go b/modules/memory-pressure/cmd/memory-pressure/helpers_test.go new file mode 100644 index 0000000..09adcdf --- /dev/null +++ b/modules/memory-pressure/cmd/memory-pressure/helpers_test.go @@ -0,0 +1,80 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +// fake is a machine for a test: a tree standing in for /, and a runner answering from a table and +// recording every command it was asked to run. +type fake struct { + t *testing.T + root string + mu sync.Mutex + answers map[string]string + fails map[string]error + calls []string +} + +func newFake(t *testing.T) *fake { + t.Helper() + return &fake{t: t, root: t.TempDir(), answers: map[string]string{}, fails: map[string]error{}} +} + +func (f *fake) machine() *Machine { return &Machine{Root: f.root, Run: f.run} } + +func (f *fake) run(_ context.Context, name string, args ...string) (string, error) { + line := strings.TrimSpace(name + " " + strings.Join(args, " ")) + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, line) + if err, ok := f.fails[line]; ok { + return "", err + } + if out, ok := f.answers[line]; ok { + return out, nil + } + if err, ok := f.fails[name]; ok { + return "", err + } + return "", nil +} + +func (f *fake) called(line string) bool { + f.mu.Lock() + defer f.mu.Unlock() + for _, c := range f.calls { + if c == line { + return true + } + } + return false +} + +func (f *fake) callsLike(prefix string) []string { + f.mu.Lock() + defer f.mu.Unlock() + var out []string + for _, c := range f.calls { + if strings.HasPrefix(c, prefix) { + out = append(out, c) + } + } + return out +} + +// file writes a file under the fake root. +func (f *fake) file(path, content string) { + f.t.Helper() + full := filepath.Join(f.root, path) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + f.t.Fatal(err) + } +} diff --git a/modules/memory-pressure/cmd/memory-pressure/machine.go b/modules/memory-pressure/cmd/memory-pressure/machine.go new file mode 100644 index 0000000..a395930 --- /dev/null +++ b/modules/memory-pressure/cmd/memory-pressure/machine.go @@ -0,0 +1,106 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" +) + +// CommandTimeout bounds every command a tool or the guard runs: a journal that takes long to search +// must cost a tool call twenty seconds, never the runtime's thirty. +const CommandTimeout = 20 * time.Second + +// Runner runs one command and answers its standard output. It is injected so that every tool is +// tested against recorded answers rather than this machine's daemons. +type Runner func(ctx context.Context, name string, args ...string) (string, error) + +// ExecRunner runs a command on the machine, bounded by CommandTimeout. A failure carries what the +// command said on stderr, because "exit status 1" names nothing. +func ExecRunner(ctx context.Context, name string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(ctx, CommandTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + var stdout, stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = &stdout, &stderr + err := cmd.Run() + if ctx.Err() == context.DeadlineExceeded { + return stdout.String(), fmt.Errorf("%s did not answer within %s", name, CommandTimeout) + } + if err != nil { + said := strings.TrimSpace(stderr.String()) + if said == "" { + said = strings.TrimSpace(stdout.String()) + } + if said != "" { + return stdout.String(), fmt.Errorf("%s %s: %w: %s", name, strings.Join(args, " "), err, said) + } + return stdout.String(), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err) + } + return stdout.String(), nil +} + +// Machine is what the module reads and acts on: a filesystem root (the real one, or a test's tree of +// /proc, /sys and /etc) and a way to run commands. +type Machine struct { + Root string + Run Runner +} + +// Here is the machine this process runs on. +func Here() *Machine { return &Machine{Root: "/", Run: ExecRunner} } + +func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) } + +// read is a file's content, trimmed; "" when it cannot be read. +func (m *Machine) read(p string) string { + b, err := os.ReadFile(m.path(p)) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +// readInt is a file holding one integer; ok false when it is absent or not a number. +func (m *Machine) readInt(p string) (int64, bool) { + s := m.read(p) + if s == "" { + return 0, false + } + n, err := strconv.ParseInt(s, 10, 64) + return n, err == nil +} + +func (m *Machine) glob(pattern string) []string { + found, _ := filepath.Glob(m.path(pattern)) + out := make([]string, 0, len(found)) + for _, f := range found { + rel, err := filepath.Rel(m.Root, f) + if err != nil { + continue + } + out = append(out, "/"+filepath.ToSlash(rel)) + } + return out +} + +// notInstalled says a command failed because it is not on this machine at all. +func notInstalled(err error) bool { return errors.Is(err, exec.ErrNotFound) } + +// round to one decimal, for watts and percentages a person reads. +func round1(f float64) float64 { + return float64(int64(f*10+sign(f)*0.5)) / 10 +} + +func sign(f float64) float64 { + if f < 0 { + return -1 + } + return 1 +} diff --git a/modules/memory-pressure/cmd/memory-pressure/main.go b/modules/memory-pressure/cmd/memory-pressure/main.go new file mode 100644 index 0000000..b691c30 --- /dev/null +++ b/modules/memory-pressure/cmd/memory-pressure/main.go @@ -0,0 +1,24 @@ +// The memory-pressure module's Go bundle (novox/hq ADR 0188, ADR 0193, ADR 0198): one process the +// node's runtime launches, serving the module's tools over MCP on stdio and running its long-running +// code — the guard that warns before the machine kills for memory — beside them. +package main + +import ( + "context" + "fmt" + "os" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + m := Here() + g := NewGuard(m, func(eventType string, body any) error { return stdio.Emit(eventType, body) }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go g.Run(ctx) + if err := stdio.Serve("", Tools(m, g)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/modules/memory-pressure/cmd/memory-pressure/manifest_test.go b/modules/memory-pressure/cmd/memory-pressure/manifest_test.go new file mode 100644 index 0000000..7686ef4 --- /dev/null +++ b/modules/memory-pressure/cmd/memory-pressure/manifest_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" +) + +func TestTheManifestNamesExactlyTheToolsTheBundleServesAndNoMachine(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + Tools []string `json:"tools"` + Emits []string `json:"emits"` + Resources []map[string]any `json:"resources"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + var served []string + for _, tool := range Tools(newFake(t).machine(), nil) { + served = append(served, tool.Name) + } + sort.Strings(served) + sort.Strings(m.Tools) + if strings.Join(served, ",") != strings.Join(m.Tools, ",") { + t.Fatalf("served %v, listed %v", served, m.Tools) + } + sort.Strings(m.Emits) + if strings.Join(m.Emits, ",") != "pressure.cleared,pressure.high" { + t.Fatalf("emits %v", m.Emits) + } + for _, banned := range []string{"g14", "jochen", "/home/", "shanks", "/run/user/1000"} { + if strings.Contains(string(raw), banned) { + t.Errorf("the manifest says %q", banned) + } + } + ids := map[string]bool{} + for _, r := range m.Resources { + ids[r["id"].(string)] = true + } + for _, r := range m.Resources { + list, _ := r["restart-on"].([]any) + for _, id := range list { + if !ids[id.(string)] { + t.Errorf("%s restarts on %v, which is not a resource", r["id"], id) + } + } + } +} diff --git a/modules/memory-pressure/cmd/memory-pressure/memory.go b/modules/memory-pressure/cmd/memory-pressure/memory.go new file mode 100644 index 0000000..279a424 --- /dev/null +++ b/modules/memory-pressure/cmd/memory-pressure/memory.go @@ -0,0 +1,300 @@ +package main + +import ( + "fmt" + "path" + "sort" + "strconv" + "strings" +) + +const kib = 1024 + +// GiB turns bytes into gibibytes with one decimal, for a person reading. +func GiB(bytes int64) float64 { return round1(float64(bytes) / (1 << 30)) } + +// ParseMeminfo reads /proc/meminfo into bytes by field. +func ParseMeminfo(text string) map[string]int64 { + out := map[string]int64{} + for _, line := range strings.Split(text, "\n") { + key, rest, ok := strings.Cut(line, ":") + if !ok { + continue + } + f := strings.Fields(rest) + if len(f) == 0 { + continue + } + n, err := strconv.ParseInt(f[0], 10, 64) + if err != nil { + continue + } + if len(f) > 1 && f[1] == "kB" { + n *= kib + } + out[key] = n + } + return out +} + +// Pressure is one line of a PSI file: the share of time some (or all) tasks stalled on memory. +type Pressure struct { + Avg10 float64 `json:"avg10"` + Avg60 float64 `json:"avg60"` + Avg300 float64 `json:"avg300"` +} + +// ParsePSI reads /proc/pressure/memory: its `some` and `full` lines. +func ParsePSI(text string) (some, full *Pressure) { + for _, line := range strings.Split(text, "\n") { + f := strings.Fields(line) + if len(f) < 4 { + continue + } + p := &Pressure{} + for _, kv := range f[1:] { + k, v, _ := strings.Cut(kv, "=") + n, _ := strconv.ParseFloat(v, 64) + switch k { + case "avg10": + p.Avg10 = n + case "avg60": + p.Avg60 = n + case "avg300": + p.Avg300 = n + } + } + switch f[0] { + case "some": + some = p + case "full": + full = p + } + } + return some, full +} + +// SwapDevice is one line of /proc/swaps. +type SwapDevice struct { + Name string `json:"name"` + Type string `json:"type"` + SizeGiB float64 `json:"size_gib"` + UsedGiB float64 `json:"used_gib"` + Priority int `json:"priority"` +} + +// ParseSwaps reads /proc/swaps (sizes in KiB). +func ParseSwaps(text string) []SwapDevice { + var out []SwapDevice + for i, line := range strings.Split(text, "\n") { + f := strings.Fields(line) + if i == 0 || len(f) < 5 { + continue + } + size, _ := strconv.ParseInt(f[2], 10, 64) + used, _ := strconv.ParseInt(f[3], 10, 64) + prio, _ := strconv.Atoi(f[4]) + out = append(out, SwapDevice{Name: f[0], Type: f[1], SizeGiB: GiB(size * kib), UsedGiB: GiB(used * kib), Priority: prio}) + } + return out +} + +// Snapshot is the machine's memory at one moment: what the status tool answers and what the guard +// judges. +type Snapshot struct { + TotalGiB float64 `json:"total_gib"` + AvailableGiB float64 `json:"available_gib"` + UsedPercent float64 `json:"used_percent"` + SwapTotalGiB float64 `json:"swap_total_gib"` + SwapUsedGiB float64 `json:"swap_used_gib"` + SwapPercent float64 `json:"swap_used_percent"` + Swaps []SwapDevice `json:"swap_devices"` + Zram []Zram `json:"zram"` + PressureSome *Pressure `json:"pressure_some,omitempty"` + PressureFull *Pressure `json:"pressure_full,omitempty"` +} + +// Snapshot reads /proc and /sys. +func (m *Machine) Snapshot() (Snapshot, error) { + mem := ParseMeminfo(m.read("/proc/meminfo")) + total, avail := mem["MemTotal"], mem["MemAvailable"] + if total <= 0 { + return Snapshot{}, fmt.Errorf("/proc/meminfo says no MemTotal") + } + s := Snapshot{ + TotalGiB: GiB(total), AvailableGiB: GiB(avail), + UsedPercent: round1(float64(total-avail) / float64(total) * 100), + SwapTotalGiB: GiB(mem["SwapTotal"]), + SwapUsedGiB: GiB(mem["SwapTotal"] - mem["SwapFree"]), + Swaps: ParseSwaps(m.read("/proc/swaps")), + Zram: m.Zram(), + } + if mem["SwapTotal"] > 0 { + s.SwapPercent = round1(float64(mem["SwapTotal"]-mem["SwapFree"]) / float64(mem["SwapTotal"]) * 100) + } + if s.Swaps == nil { + s.Swaps = []SwapDevice{} + } + s.PressureSome, s.PressureFull = ParsePSI(m.read("/proc/pressure/memory")) + return s, nil +} + +// Zram is one compressed swap device in RAM. +type Zram struct { + Device string `json:"device"` + Algorithm string `json:"algorithm"` + DiskSizeGiB float64 `json:"disk_size_gib"` + StoredGiB float64 `json:"stored_gib"` + CompressedGiB float64 `json:"compressed_gib"` + RAMUsedGiB float64 `json:"ram_used_gib"` + Ratio *float64 `json:"compression_ratio,omitempty"` + SamePages int64 `json:"same_filled_pages"` + HugePages int64 `json:"incompressible_pages"` +} + +// Zram reads every zram device's statistics from /sys/block. +func (m *Machine) Zram() []Zram { + out := []Zram{} + for _, dir := range m.glob("/sys/block/zram*") { + z := Zram{Device: path.Base(dir), Algorithm: activeAlgorithm(m.read(dir + "/comp_algorithm"))} + if v, ok := m.readInt(dir + "/disksize"); ok { + z.DiskSizeGiB = GiB(v) + } + // mm_stat: orig_data_size compr_data_size mem_used_total mem_limit mem_used_max same_pages + // pages_compacted huge_pages … + f := strings.Fields(m.read(dir + "/mm_stat")) + n := func(i int) int64 { + if i >= len(f) { + return 0 + } + v, _ := strconv.ParseInt(f[i], 10, 64) + return v + } + if len(f) >= 3 { + z.StoredGiB, z.CompressedGiB, z.RAMUsedGiB = GiB(n(0)), GiB(n(1)), GiB(n(2)) + z.SamePages, z.HugePages = n(5), n(7) + if n(1) > 0 { + r := round1(float64(n(0)) / float64(n(1))) + z.Ratio = &r + } + } + out = append(out, z) + } + return out +} + +// activeAlgorithm is the bracketed one of `lzo lz4 [zstd]`. +func activeAlgorithm(s string) string { + for _, f := range strings.Fields(s) { + if strings.HasPrefix(f, "[") { + return strings.Trim(f, "[]") + } + } + return s +} + +// Process is one process by the memory it holds. +type Process struct { + PID int `json:"pid"` + Name string `json:"name"` + RSSMiB float64 `json:"rss_mib"` + SwapMiB float64 `json:"swap_mib"` + Unit string `json:"unit,omitempty"` + Command string `json:"command,omitempty"` +} + +// Group is the memory of every process in one systemd unit or scope. +type Group struct { + Unit string `json:"unit"` + Processes int `json:"processes"` + RSSMiB float64 `json:"rss_mib"` + SwapMiB float64 `json:"swap_mib"` + Largest string `json:"largest"` +} + +// unitOf is the last named unit in a process's cgroup path: the scope or service it lives in, which +// is what systemd-oomd chooses among. +func unitOf(cgroup string) string { + line := strings.TrimSpace(strings.SplitN(cgroup, "\n", 2)[0]) + _, p, _ := strings.Cut(line, "::") + parts := strings.Split(p, "/") + for i := len(parts) - 1; i >= 0; i-- { + if strings.HasSuffix(parts[i], ".scope") || strings.HasSuffix(parts[i], ".service") || strings.HasSuffix(parts[i], ".slice") { + return parts[i] + } + } + return p +} + +// Processes reads every process's resident and swapped memory. A process that ends while it is read +// is skipped. +func (m *Machine) Processes() []Process { + var out []Process + for _, dir := range m.glob("/proc/[0-9]*") { + status := m.read(dir + "/status") + if status == "" { + continue + } + fields := ParseMeminfo(status) + name := "" + for _, line := range strings.Split(status, "\n") { + if v, ok := strings.CutPrefix(line, "Name:"); ok { + name = strings.TrimSpace(v) + break + } + } + rss := fields["VmRSS"] + swap := fields["VmSwap"] + if rss == 0 && swap == 0 { + continue // a kernel thread + } + pid, _ := strconv.Atoi(path.Base(dir)) + cmd := strings.TrimSpace(strings.ReplaceAll(m.read(dir+"/cmdline"), "\x00", " ")) + if len(cmd) > 160 { + cmd = cmd[:160] + "…" + } + out = append(out, Process{PID: pid, Name: name, RSSMiB: mib(rss), SwapMiB: mib(swap), + Unit: unitOf(m.read(dir + "/cgroup")), Command: cmd}) + } + return out +} + +func mib(b int64) float64 { return round1(float64(b) / (1 << 20)) } + +// Top is the largest processes by resident plus swapped memory. +func Top(ps []Process, limit int) []Process { + sort.Slice(ps, func(i, j int) bool { return ps[i].RSSMiB+ps[i].SwapMiB > ps[j].RSSMiB+ps[j].SwapMiB }) + if len(ps) > limit { + ps = ps[:limit] + } + return ps +} + +// ByUnit sums processes by their unit, largest first. +func ByUnit(ps []Process, limit int) []Group { + groups := map[string]*Group{} + biggest := map[string]float64{} + for _, p := range ps { + g := groups[p.Unit] + if g == nil { + g = &Group{Unit: p.Unit} + groups[p.Unit] = g + } + g.Processes++ + g.RSSMiB = round1(g.RSSMiB + p.RSSMiB) + g.SwapMiB = round1(g.SwapMiB + p.SwapMiB) + if p.RSSMiB+p.SwapMiB > biggest[p.Unit] { + biggest[p.Unit] = p.RSSMiB + p.SwapMiB + g.Largest = p.Name + } + } + out := make([]Group, 0, len(groups)) + for _, g := range groups { + out = append(out, *g) + } + sort.Slice(out, func(i, j int) bool { return out[i].RSSMiB+out[i].SwapMiB > out[j].RSSMiB+out[j].SwapMiB }) + if len(out) > limit { + out = out[:limit] + } + return out +} diff --git a/modules/memory-pressure/cmd/memory-pressure/memory_test.go b/modules/memory-pressure/cmd/memory-pressure/memory_test.go new file mode 100644 index 0000000..e4908fe --- /dev/null +++ b/modules/memory-pressure/cmd/memory-pressure/memory_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +const meminfo = `MemTotal: 32000000 kB +MemFree: 905460 kB +MemAvailable: 1000000 kB +SwapTotal: 10000000 kB +SwapFree: 5000000 kB +` + +// As /proc/swaps and zram read on the laptop on 2026-10-04. +const swaps = `Filename Type Size Used Priority +/swapfile file 33554428 0 10 +/dev/zram0 partition 16059900 7235584 100 +` + +func (f *fake) machineWith(mem, psi string) *Machine { + f.file("/proc/meminfo", mem) + f.file("/proc/swaps", swaps) + f.file("/proc/pressure/memory", psi) + f.file("/sys/block/zram0/comp_algorithm", "lzo-rle lzo lz4 lz4hc [zstd] deflate 842\n") + f.file("/sys/block/zram0/disksize", "16445341696\n") + f.file("/sys/block/zram0/mm_stat", "14368768 5341499 6680576 0 6680576 2 0 0 0\n") + return f.machine() +} + +const calm = "some avg10=0.00 avg60=0.02 avg300=0.00 total=14487028\nfull avg10=0.00 avg60=0.01 avg300=0.00 total=14230772\n" + +func TestTheSnapshotReadsRAMSwapZramAndPressure(t *testing.T) { + f := newFake(t) + s, err := f.machineWith(meminfo, calm).Snapshot() + if err != nil { + t.Fatal(err) + } + if s.UsedPercent != 96.9 || s.SwapPercent != 50 || len(s.Swaps) != 2 || s.Swaps[1].Priority != 100 { + t.Fatalf("%+v", s) + } + z := s.Zram[0] + if z.Algorithm != "zstd" || *z.Ratio != 2.7 || z.DiskSizeGiB != 15.3 || z.SamePages != 2 { + t.Fatalf("%+v", z) + } + if s.PressureSome == nil || s.PressureSome.Avg60 != 0.02 || s.PressureFull == nil { + t.Fatalf("%+v %+v", s.PressureSome, s.PressureFull) + } +} + +func TestTheGuardWarnsOnWhicheverLineIsCrossedFirst(t *testing.T) { + for name, c := range map[string]struct { + s Snapshot + want string + }{ + "ram": {Snapshot{UsedPercent: 96}, "RAM"}, + "swap": {Snapshot{UsedPercent: 50, SwapTotalGiB: 10, SwapPercent: 85}, "swap"}, + "pressure": {Snapshot{UsedPercent: 50, PressureSome: &Pressure{Avg10: 45}}, "stalled"}, + } { + v := Judge(c.s) + if !v.High || len(v.Reasons) != 1 || !strings.Contains(v.Reasons[0], c.want) || v.Clear { + t.Errorf("%s: %+v", name, v) + } + } + if v := Judge(Snapshot{UsedPercent: 90}); v.High || v.Clear { + t.Errorf("between the lines is neither a warning nor clear: %+v", v) + } + if v := Judge(Snapshot{UsedPercent: 50, PressureSome: &Pressure{}}); v.High || !v.Clear { + t.Errorf("%+v", v) + } +} + +func TestTheGuardWarnsOnceNamesTheLargestAndClearsOnlyBelowEveryLine(t *testing.T) { + f := newFake(t) + m := f.machineWith(meminfo, calm) + f.file("/proc/4242/status", "Name:\tfirefox\nVmRSS:\t 6291456 kB\nVmSwap:\t 1048576 kB\n") + f.file("/proc/4242/cgroup", "0::/user.slice/user-1000.slice/session-1.scope\n") + f.file("/proc/4242/cmdline", "/usr/lib/firefox/firefox\x00") + var events []string + g := NewGuard(m, func(t string, _ any) error { events = append(events, t); return nil }) + var notes []string + g.notify = func(_ context.Context, summary, body string) error { + notes = append(notes, body) + return nil + } + ctx := context.Background() + g.Check(ctx) + g.Check(ctx) + if len(notes) != 1 || !strings.Contains(notes[0], "firefox 7.0 GiB") || !strings.Contains(notes[0], "RAM 97%") { + t.Fatalf("%v", notes) + } + // Between the lines: still latched, nothing new. + f.file("/proc/meminfo", strings.Replace(meminfo, "MemAvailable: 1000000", "MemAvailable: 3200000", 1)) + g.Check(ctx) + if len(events) != 1 || !g.Report().Warned { + t.Fatalf("%v %+v", events, g.Report()) + } + f.file("/proc/meminfo", "MemTotal: 32000000 kB\nMemAvailable: 20000000 kB\nSwapTotal: 10000000 kB\nSwapFree: 9000000 kB\n") + g.Check(ctx) + if strings.Join(events, ",") != "pressure.high,pressure.cleared" || g.Report().Warned { + t.Fatalf("%v", events) + } +} + +func TestADesktopThatCannotBeReachedIsSaidAndTheEventStillGoes(t *testing.T) { + f := newFake(t) + m := f.machineWith(meminfo, calm) + var events []string + g := NewGuard(m, func(t string, _ any) error { events = append(events, t); return nil }) + g.notify = func(context.Context, string, string) error { return errors.New("no session bus at /run/user/1000/bus") } + g.Check(context.Background()) + if r := g.Report(); !strings.HasPrefix(r.Desktop, "not reached") || len(events) != 1 { + t.Fatalf("%+v %v", r, events) + } +} + +func TestTheNotificationIsOneBusctlCallOnTheAccountsBus(t *testing.T) { + f := newFake(t) + g := NewGuard(f.machine(), nil) + bus, err := Bus() + if err != nil { + t.Skip(err) + } + err = g.desktopNotify(context.Background(), "s", "b") + calls := f.callsLike("env DBUS_SESSION_BUS_ADDRESS=unix:path=" + bus + " busctl --user call org.freedesktop.Notifications") + if err == nil && len(calls) != 1 { + t.Fatalf("%v", f.calls) + } + if err != nil && !strings.Contains(err.Error(), "no session bus") { + t.Fatal(err) + } +} + +func TestTheLargestAreSummedByTheUnitOomdChoosesAmong(t *testing.T) { + ps := []Process{ + {PID: 1, Name: "firefox", RSSMiB: 600, Unit: "session-1.scope"}, + {PID: 2, Name: "Isolated Web Co", RSSMiB: 900, Unit: "session-1.scope"}, + {PID: 3, Name: "postgres", RSSMiB: 100, Unit: "docker-abc.scope"}, + } + g := ByUnit(ps, 10) + if len(g) != 2 || g[0].Unit != "session-1.scope" || g[0].RSSMiB != 1500 || g[0].Largest != "Isolated Web Co" || g[0].Processes != 2 { + t.Fatalf("%+v", g) + } + if top := Top(ps, 1); top[0].PID != 2 { + t.Fatalf("%+v", top) + } + if u := unitOf("0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-foot-123.scope\n"); u != "app-foot-123.scope" { + t.Fatal(u) + } +} + +func TestTheJournalIsReadForEveryKindOfKillAndSinceIsChecked(t *testing.T) { + journal := strings.Join([]string{ + `{"__REALTIME_TIMESTAMP":"1759500000000000","MESSAGE":"Out of memory: Killed process 4242 (firefox) total-vm:1kB","_TRANSPORT":"kernel"}`, + `{"__REALTIME_TIMESTAMP":"1759600000000000","MESSAGE":"Killed /user.slice/user-1000.slice/session-1.scope due to memory pressure for /user.slice being 84.12% > 80.00% for > 20s with reclaim activity","_SYSTEMD_UNIT":"systemd-oomd.service"}`, + `{"__REALTIME_TIMESTAMP":"1759700000000000","MESSAGE":"docker.service: A process of this unit has been killed by the OOM killer.","_SYSTEMD_UNIT":"init.scope"}`, + `{"__REALTIME_TIMESTAMP":"1759800000000000","MESSAGE":"Killed something else entirely","_SYSTEMD_UNIT":"bash.service"}`, + `{"MESSAGE":[1,2,3]}`, + }, "\n") + kills := ParseJournal(journal) + if len(kills) != 3 || kills[0].By != "service manager" || kills[0].Victim != "docker.service" || + kills[1].By != "systemd-oomd" || kills[2].Victim != "firefox (pid 4242)" || kills[2].Time.Year() != 2025 { + t.Fatalf("%+v", kills) + } + f := newFake(t) + m := f.machine() + if _, err := m.OOMHistory(context.Background(), "--output=x", 10); err == nil { + t.Fatal("an option was passed as since") + } + f.fails["journalctl --no-pager -q -o json --since -7 days -g Out of memory: Killed process|Killed .* due to|has been killed by the OOM killer"] = errors.New("journalctl: exit status 1") + k, err := m.OOMHistory(context.Background(), "7d", 10) + if err != nil || len(k) != 0 { + t.Fatalf("no match is no kills: %v %v", k, err) + } + _ = time.Now +} diff --git a/modules/memory-pressure/cmd/memory-pressure/oom.go b/modules/memory-pressure/cmd/memory-pressure/oom.go new file mode 100644 index 0000000..36a9a46 --- /dev/null +++ b/modules/memory-pressure/cmd/memory-pressure/oom.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "regexp" + "sort" + "strconv" + "strings" + "time" +) + +// Kill is one thing the machine killed for memory, from the journal. +type Kill struct { + Time time.Time `json:"time"` + By string `json:"by"` + Victim string `json:"victim,omitempty"` + Message string `json:"message"` +} + +// The three voices that report a kill: the kernel's OOM killer, systemd-oomd, and the service manager +// saying one of its units lost a process to the OOM killer. +var ( + kernelKill = regexp.MustCompile(`Out of memory: Killed process (\d+) \(([^)]*)\)`) + oomdKill = regexp.MustCompile(`Killed (\S+) due to (.+)`) + unitKilled = regexp.MustCompile(`^(\S+): A process of this unit has been killed by the OOM killer`) +) + +// sinceArg is a value journalctl understands after --since; anything else is refused before it +// reaches the command line. +var sinceArg = regexp.MustCompile(`^(-?\d+[smhdw]|today|yesterday|\d{4}-\d{2}-\d{2}( \d{2}:\d{2}(:\d{2})?)?)$`) + +// ParseJournal reads `journalctl -o json` lines into kills, newest first. +func ParseJournal(text string) []Kill { + var out []Kill + for _, line := range strings.Split(text, "\n") { + if strings.TrimSpace(line) == "" { + continue + } + var e map[string]any + if json.Unmarshal([]byte(line), &e) != nil { + continue + } + msg, ok := e["MESSAGE"].(string) // a message that is not UTF-8 arrives as bytes; not one of these + if !ok { + continue + } + var k Kill + if us, err := strconv.ParseInt(fmt.Sprint(e["__REALTIME_TIMESTAMP"]), 10, 64); err == nil { + k.Time = time.UnixMicro(us).UTC() + } + switch { + case kernelKill.MatchString(msg): + g := kernelKill.FindStringSubmatch(msg) + k.By, k.Victim = "kernel", g[2]+" (pid "+g[1]+")" + case oomdKill.MatchString(msg) && strings.Contains(fmt.Sprint(e["_SYSTEMD_UNIT"], e["SYSLOG_IDENTIFIER"]), "oomd"): + g := oomdKill.FindStringSubmatch(msg) + k.By, k.Victim = "systemd-oomd", g[1] + case unitKilled.MatchString(msg): + k.By, k.Victim = "service manager", unitKilled.FindStringSubmatch(msg)[1] + default: + continue + } + k.Message = msg + out = append(out, k) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].Time.After(out[j].Time) }) + return out +} + +// OOMHistory searches the journal since a time for every kill. +func (m *Machine) OOMHistory(ctx context.Context, since string, limit int) ([]Kill, error) { + if !sinceArg.MatchString(since) { + return nil, fmt.Errorf("since %q is not -7d, 12h, today, yesterday or a date (2026-10-01)", since) + } + since = expandRelative(since) + out, err := m.Run(ctx, "journalctl", "--no-pager", "-q", "-o", "json", "--since", since, + "-g", "Out of memory: Killed process|Killed .* due to|has been killed by the OOM killer") + if err != nil && strings.TrimSpace(out) == "" { + if strings.HasSuffix(err.Error(), "exit status 1") { + return []Kill{}, nil // journalctl exits 1 when a grep matches nothing + } + return nil, err + } + kills := ParseJournal(out) + if kills == nil { + kills = []Kill{} + } + if len(kills) > limit { + kills = kills[:limit] + } + return kills, nil +} + +// expandRelative turns -7d (or 7d) into the "-7 days" form journalctl's --since reads. +func expandRelative(s string) string { + g := regexp.MustCompile(`^-?(\d+)([smhdw])$`).FindStringSubmatch(s) + if g == nil { + return s + } + unit := map[string]string{"s": "seconds", "m": "minutes", "h": "hours", "d": "days", "w": "weeks"}[g[2]] + return "-" + g[1] + " " + unit +} + +// Oomd is what systemd-oomd says it watches. +type Oomd struct { + Active string `json:"active"` + Enabled string `json:"enabled"` + Report []string `json:"oomctl"` + Note string `json:"note,omitempty"` +} + +func (m *Machine) Oomd(ctx context.Context) Oomd { + o := Oomd{Report: []string{}} + a, _ := m.Run(ctx, "systemctl", "is-active", "systemd-oomd.service") + e, _ := m.Run(ctx, "systemctl", "is-enabled", "systemd-oomd.service") + o.Active, o.Enabled = strings.TrimSpace(a), strings.TrimSpace(e) + out, err := m.Run(ctx, "oomctl") + if err != nil { + o.Note = "oomctl: " + err.Error() + } + for _, line := range strings.Split(out, "\n") { + if strings.TrimSpace(line) != "" { + o.Report = append(o.Report, strings.TrimRight(strings.ReplaceAll(line, "\t", " "), " ")) + } + } + return o +} diff --git a/modules/memory-pressure/cmd/memory-pressure/tools.go b/modules/memory-pressure/cmd/memory-pressure/tools.go new file mode 100644 index 0000000..fa23e36 --- /dev/null +++ b/modules/memory-pressure/cmd/memory-pressure/tools.go @@ -0,0 +1,145 @@ +package main + +import ( + "context" + "fmt" + "math" + "strconv" + "strings" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +// Tools is the module's tools over one machine and its guard. +func Tools(m *Machine, g *Guard) []stdio.Tool { + ctx := context.Background + return []stdio.Tool{ + { + Name: "memory_status", + Description: "Memory now: RAM total, available and used, swap and each swap device with its priority, the compressed swap in RAM " + + "(zram) and its ratio, and pressure stall (PSI some/full, avg10/60/300) — with the guard's verdict on it.", + Run: func(map[string]any) (any, error) { + s, err := m.Snapshot() + if err != nil { + return nil, err + } + return map[string]any{"memory": s, "verdict": Judge(s)}, nil + }, + }, + { + Name: "memory_top", + Description: "The largest processes by resident plus swapped memory, with the systemd unit each runs in; by=unit sums them per unit, which is what systemd-oomd chooses among.", + Input: map[string]any{ + "limit": map[string]any{"type": "integer", "description": "how many (default 10, at most 50)"}, + "by": map[string]any{"type": "string", "enum": []string{"process", "unit"}}, + }, + Run: func(args map[string]any) (any, error) { + limit, err := bounded(args, "limit", 10, 50) + if err != nil { + return nil, err + } + ps := m.Processes() + switch str(args, "by") { + case "", "process": + return map[string]any{"processes": orEmpty(Top(ps, limit))}, nil + case "unit": + return map[string]any{"units": orEmpty(ByUnit(ps, limit))}, nil + } + return nil, fmt.Errorf("by is process or unit") + }, + }, + { + Name: "memory_oom_history", + Description: "What was killed for memory, newest first, from the journal: the kernel's OOM killer, systemd-oomd, and units the service manager says lost a process to it.", + Input: map[string]any{ + "since": map[string]any{"type": "string", "description": "-7d (default), 12h, today, yesterday or a date like 2026-10-01"}, + "limit": map[string]any{"type": "integer", "description": "how many (default 20, at most 100)"}, + }, + Run: func(args map[string]any) (any, error) { + limit, err := bounded(args, "limit", 20, 100) + if err != nil { + return nil, err + } + since := str(args, "since") + if since == "" { + since = "-7d" + } + kills, err := m.OOMHistory(ctx(), since, limit) + if err != nil { + return nil, err + } + return map[string]any{"since": since, "kills": kills}, nil + }, + }, + { + Name: "memory_zram", + Description: "The compressed swap in RAM: each zram device's algorithm, size, what it stores, what that costs in RAM and the ratio, the generator's configuration, and the swap tunables beside it.", + Run: func(map[string]any) (any, error) { + return map[string]any{ + "devices": m.Zram(), + "configuration": m.read("/etc/systemd/zram-generator.conf"), + "vm": map[string]string{ + "swappiness": m.read("/proc/sys/vm/swappiness"), + "page-cluster": m.read("/proc/sys/vm/page-cluster"), + }, + "note": "a change to the configuration applies at the next boot: swapping the device off to resize it would push what it holds back into RAM", + }, nil + }, + }, + { + Name: "memory_oomd", + Description: "systemd-oomd: whether it runs, and what it watches — the cgroups, their limits and their pressure, as oomctl reports them.", + Run: func(map[string]any) (any, error) { return m.Oomd(ctx()), nil }, + }, + { + Name: "memory_guard", + Description: "The module's guard that warns before the machine kills for memory: its thresholds, its last verdict, whether a warning stands, and whether the operator's desktop can be reached.", + Run: func(map[string]any) (any, error) { + if g == nil { + return nil, fmt.Errorf("the guard does not run in this process") + } + return g.Report(), nil + }, + }, + } +} + +func str(args map[string]any, key string) string { + s, _ := args[key].(string) + return strings.TrimSpace(s) +} + +// bounded is an integer argument, defaulted, at least 1 and at most most. +func bounded(args map[string]any, key string, fallback, most int) (int, error) { + v, given := args[key] + if !given || v == nil { + return fallback, nil + } + var n int + switch x := v.(type) { + case float64: + if x != math.Trunc(x) { + return 0, fmt.Errorf("%s must be a whole number, not %v", key, x) + } + n = int(x) + case string: + i, err := strconv.Atoi(strings.TrimSpace(x)) + if err != nil { + return 0, fmt.Errorf("%s must be a whole number, not %q", key, x) + } + n = i + default: + return 0, fmt.Errorf("%s must be a whole number", key) + } + if n < 1 { + return 0, fmt.Errorf("%s must be at least 1", key) + } + return min(n, most), nil +} + +func orEmpty[T any](s []T) []T { + if s == nil { + return []T{} + } + return s +} diff --git a/modules/memory-pressure/go.mod b/modules/memory-pressure/go.mod new file mode 100644 index 0000000..48aaf9a --- /dev/null +++ b/modules/memory-pressure/go.mod @@ -0,0 +1,5 @@ +module memorypressure + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/memory-pressure/go.sum b/modules/memory-pressure/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/memory-pressure/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ= +git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/memory-pressure/module.json b/modules/memory-pressure/module.json new file mode 100644 index 0000000..95eeb71 --- /dev/null +++ b/modules/memory-pressure/module.json @@ -0,0 +1,121 @@ +{ + "module": "memory-pressure", + "version": "1", + "capabilities": [ + "package-manager", + "service-manager" + ], + "emits": [ + "pressure.high", + "pressure.cleared" + ], + "tools": [ + "memory_status", + "memory_top", + "memory_oom_history", + "memory_zram", + "memory_oomd", + "memory_guard" + ], + "resources": [ + { + "id": "zram-generator", + "type": "package", + "package": "zram-generator" + }, + { + "id": "zram", + "type": "file", + "path": "/etc/systemd/zram-generator.conf", + "mode": "0644", + "content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# Compressed swap in RAM: fast, at a higher priority than any swap on disk, so it takes the everyday\n# pressure before anything spills to a disk. Half the RAM, at most 16 GiB, compressed with zstd.\n# The swap on disk, its size and whether one exists at all are the machine's, not this module's.\n#\n# Read by the generator at boot: a change applies at the next boot.\n[zram0]\nzram-size = min(ram / 2, 16384)\ncompression-algorithm = zstd\nswap-priority = 100\n" + }, + { + "id": "sysctl-drop-ins", + "type": "directory", + "path": "/etc/sysctl.d", + "mode": "0755" + }, + { + "id": "swap-tunables", + "type": "file", + "path": "/etc/sysctl.d/90-memory-pressure.conf", + "mode": "0644", + "content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# Swap-in reads one page, not eight: read-ahead exists to amortise a disk's seek, and compressed swap in\n# RAM has none — the speculation only costs decompression and memory.\nvm.page-cluster = 0\n#\n# vm.swappiness is deliberately left alone. The usual zram advice (150-180) holds only while the\n# compressed swap has room; once it is full, a higher swappiness moves pages to the disk swap, the thing\n# being avoided. Raise it only together with zram-size.\n" + }, + { + "id": "sysctl", + "type": "service", + "unit": "systemd-sysctl.service", + "restart-on": [ + "swap-tunables" + ] + }, + { + "id": "oomd-drop-ins", + "type": "directory", + "path": "/etc/systemd/oomd.conf.d", + "mode": "0755" + }, + { + "id": "oomd-limits", + "type": "file", + "path": "/etc/systemd/oomd.conf.d/memory-pressure.conf", + "mode": "0644", + "content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# systemd-oomd kills the worst unit before the kernel's OOM killer freezes the machine: when swap is\n# 90 % used, or when a watched unit stalls on memory for 20 s above its limit.\n[OOM]\nSwapUsedLimit=90%\nDefaultMemoryPressureLimit=60%\nDefaultMemoryPressureDurationSec=20s\n" + }, + { + "id": "root-slice-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/-.slice.d", + "mode": "0755" + }, + { + "id": "root-slice", + "type": "file", + "path": "/etc/systemd/system/-.slice.d/10-oomd.conf", + "mode": "0644", + "content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# systemd-oomd may act on swap exhaustion across the whole machine.\n[Slice]\nManagedOOMSwap=kill\n" + }, + { + "id": "user-manager-drop-ins", + "type": "directory", + "path": "/etc/systemd/system/user@.service.d", + "mode": "0755" + }, + { + "id": "user-manager", + "type": "file", + "path": "/etc/systemd/system/user@.service.d/10-oomd.conf", + "mode": "0644", + "content": "# Managed by the mesh (module memory-pressure). Replaced on every push; edit the catalogue instead.\n#\n# systemd-oomd may kill the worst unit in a person's service manager when its memory pressure stays\n# above 80 % — instead of the kernel freezing the machine.\n[Service]\nManagedOOMMemoryPressure=kill\nManagedOOMMemoryPressureLimit=80%\n" + }, + { + "id": "oomd", + "type": "service", + "unit": "systemd-oomd.service", + "state": "running", + "boot": "enabled", + "restart-on": [ + "oomd-limits", + "root-slice", + "user-manager" + ] + } + ], + "build": { + "artifacts": [ + { + "name": "tools-go", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/memory-pressure", + "binary": "memory-pressure", + "loads": [ + "memory-pressure" + ] + } + ] + } +}