auto_suspend: busctl->shutdown now, 1min timer config, AMD JSON parsing, locale fix, unit tests

This commit is contained in:
2026-06-09 20:45:08 +02:00
parent f8a0a8ab1f
commit 80c154df03
9 changed files with 701 additions and 81 deletions
@@ -11,7 +11,6 @@ across timer invocations. State is fully reset on suspend resume.
"""
import argparse
import csv
import json
import logging
import logging.handlers
@@ -142,11 +141,17 @@ def measure_cpu(logger):
Runs ``mpstat 1 1`` and extracts the last field of the "Average:" line,
which is ``%idle``. Returns a float, or None on failure.
Uses ``LC_ALL=C`` to force English locale output regardless of system
locale (avoids issues like Portuguese ``Média:`` with comma decimals).
"""
env = dict(os.environ)
env["LC_ALL"] = "C"
try:
result = subprocess.run(
["mpstat", "1", "1"],
capture_output=True, text=True, timeout=15,
env=env,
)
if result.returncode != 0:
logger.warning("mpstat returned non-zero: %s", result.stderr.strip())
@@ -161,6 +166,7 @@ def measure_cpu(logger):
logger.debug("CPU idle: %.1f%%", idle_pct)
return idle_pct
logger.warning("mpstat output did not contain expected data line")
return None
except subprocess.TimeoutExpired:
logger.warning("mpstat timed out")
@@ -198,7 +204,7 @@ def measure_gpu_nvidia(logger):
return None
max_util = max(utils)
logger.debug("NVIDIA GPU utilization: max=%.1f%%", max_util)
logger.info("NVIDIA GPU utilization: max=%.1f%%", max_util)
return max_util
except FileNotFoundError:
logger.debug("nvidia-smi not found")
@@ -215,51 +221,45 @@ def measure_gpu_amd(logger):
"""
Measure GPU utilization via amd-smi.
Parses the ``gfx`` column from ``amd-smi monitor --csv`` by header name
(not by fixed column index). Returns None if amd-smi is unavailable.
Parses the ``gfx.value`` field from ``amd-smi monitor --json``.
Returns the utilization of the first GPU, or None if amd-smi is unavailable.
"""
try:
result = subprocess.run(
["amd-smi", "monitor", "--csv"],
["amd-smi", "monitor", "--json"],
capture_output=True, text=True, timeout=15,
)
if result.returncode != 0:
logger.debug("amd-smi returned %d — no AMD GPU", result.returncode)
return None
lines = result.stdout.strip().splitlines()
if len(lines) < 2:
logger.debug("amd-smi output too short")
raw = result.stdout.strip()
data = json.loads(result.stdout)
if not data or not isinstance(data, list):
logger.debug("amd-smi JSON output empty or invalid")
return None
# Filter out non-data lines (comments, annotations with wrong field count)
csv_reader = csv.reader(lines)
header = next(csv_reader)
for fields in csv_reader:
if len(fields) != len(header):
continue
row = dict(zip(header, fields))
try:
# Try both "gfx" and "gfx_util" column names
gfx_val = row.get("gfx") or row.get("gfx_util")
if gfx_val is None:
logger.debug("amd-smi CSV has no 'gfx' column; columns: %s",
list(row.keys()))
return None
util = float(gfx_val)
logger.debug("AMD GPU utilization: %.1f%%", util)
return util
except (ValueError, TypeError):
continue
return None
gpu = data[0]
gfx = gpu.get("gfx")
if isinstance(gfx, dict):
gfx_val = gfx.get("value")
else:
gfx_val = gfx
if gfx_val is None:
logger.debug("amd-smi JSON has no 'gfx.value' field; fields: %s",
list(gpu.keys()))
return None
util = float(gfx_val)
logger.info("AMD GPU utilization: %.1f%% (gfx=%s)", util, gfx_val)
return util
except FileNotFoundError:
logger.debug("amd-smi not found")
return None
except subprocess.TimeoutExpired:
logger.warning("amd-smi timed out")
return None
except BaseException as exc:
except (json.JSONDecodeError, ValueError, TypeError) as exc:
logger.warning("Failed to parse amd-smi output: %s", exc)
return None
@@ -339,24 +339,25 @@ def update_averages(state, cpu_n, gpu_n, alpha, logger):
# Evaluation
# ---------------------------------------------------------------------------
def get_why_not_idle(state, has_users, logger):
def get_why_not_idle(state, has_users, cpu_idle_threshold=DEFAULT_CPU_IDLE_THRESHOLD,
gpu_util_threshold=DEFAULT_GPU_UTIL_THRESHOLD, min_samples=DEFAULT_MIN_SAMPLES, logger=None):
"""Return a human-readable reason why the system is not idle."""
reasons = []
if has_users:
reasons.append("users logged in")
if state["cpu_avg"] is not None:
if state["cpu_avg"] <= DEFAULT_CPU_IDLE_THRESHOLD:
if state["cpu_avg"] <= cpu_idle_threshold:
reasons.append(
f"CPU idle avg {state['cpu_avg']:.1f}% <= threshold {DEFAULT_CPU_IDLE_THRESHOLD:.0f}%"
f"CPU idle avg {state['cpu_avg']:.1f}% <= threshold {cpu_idle_threshold:.0f}%"
)
if state["gpu_avg"] is not None:
if state["gpu_avg"] >= DEFAULT_GPU_UTIL_THRESHOLD:
if state["gpu_avg"] >= gpu_util_threshold:
reasons.append(
f"GPU util avg {state['gpu_avg']:.1f}% >= threshold {DEFAULT_GPU_UTIL_THRESHOLD:.0f}%"
f"GPU util avg {state['gpu_avg']:.1f}% >= threshold {gpu_util_threshold:.0f}%"
)
if not reasons:
if state["sample_count"] < DEFAULT_MIN_SAMPLES:
reasons.append(f"warming up ({state['sample_count']}/{DEFAULT_MIN_SAMPLES} samples)")
if state["sample_count"] < min_samples:
reasons.append(f"warming up ({state['sample_count']}/{min_samples} samples)")
else:
reasons.append("unknown")
return "; ".join(reasons)
@@ -491,17 +492,17 @@ def main():
try:
result = subprocess.run(
["systemctl", "suspend"],
["shutdown", "now"],
capture_output=True, text=True, timeout=10,
)
if result.returncode != 0:
logger.error("systemctl suspend failed: %s", result.stderr.strip())
logger.error("shutdown failed: %s", result.stderr.strip())
else:
logger.info("systemctl suspend succeeded")
logger.info("shutdown succeeded")
except subprocess.TimeoutExpired:
logger.error("systemctl suspend timed out")
logger.error("shutdown timed out")
except FileNotFoundError:
logger.error("systemctl not found — cannot suspend")
logger.error("shutdown not found — cannot suspend")
except BaseException as exc:
logger.error("Failed to suspend: %s", exc)
@@ -511,7 +512,7 @@ def main():
else:
if not args.dry_run:
state["idle_counter"] = 0
reason = get_why_not_idle(state, has_users, logger)
reason = get_why_not_idle(state, has_users, args.cpu_idle_threshold, args.gpu_util_threshold, args.min_samples, logger)
logger.info("Not idle (%s) — reset counter", reason)
# --- Save and exit ---