166 lines
5 KiB
Python
Executable file
166 lines
5 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
"""Apply one validated BAT1 charge limit and persist it through TLP.
|
|
|
|
Protocol marker: LABFY_TLP_PERSISTENT_V1. QuickShell checks the installed
|
|
root-owned helper for this marker before allowing a write.
|
|
|
|
Deployment: install this source as root:root, mode 0755, at
|
|
/usr/local/sbin/batlimit-set. The sudoers rule grants only that installed path.
|
|
|
|
Exit codes: 0 success, 2 invalid argument, 1 unavailable/apply/readback error,
|
|
3 rollback failure. stdout is exactly ``SUCCESS <value>`` on success; stderr
|
|
is exactly ``ERROR <reason>`` on failure.
|
|
"""
|
|
|
|
import fcntl
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
|
|
|
|
CONFIG = Path("/etc/tlp.d/90-labfy-charge-limit.conf")
|
|
SYSFS = Path("/sys/class/power_supply/BAT1/charge_control_end_threshold")
|
|
LOCK = Path("/run/lock/labfy-batlimit-set.lock")
|
|
TLP = "/usr/bin/tlp"
|
|
|
|
|
|
def fail(reason: str, code: int = 1) -> int:
|
|
print(f"ERROR {reason}", file=sys.stderr)
|
|
return code
|
|
|
|
|
|
def read_threshold() -> int:
|
|
raw = SYSFS.read_text(encoding="ascii").strip()
|
|
if not re.fullmatch(r"[0-9]{1,3}", raw, re.ASCII):
|
|
raise ValueError("invalid kernel threshold")
|
|
return int(raw)
|
|
|
|
|
|
def prior_config() -> bytes | None:
|
|
try:
|
|
metadata = CONFIG.lstat()
|
|
except FileNotFoundError:
|
|
return None
|
|
# CONTRACT: never follow or replace a link supplied at this privileged path.
|
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0 or metadata.st_size > 4096:
|
|
raise ValueError("unsafe TLP config")
|
|
return CONFIG.read_bytes()
|
|
|
|
|
|
def sync_directory() -> None:
|
|
fd = os.open(CONFIG.parent, os.O_RDONLY | os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def replace_config(content: bytes) -> None:
|
|
# INVARIANT: TLP must see either the entire old file or the entire new file.
|
|
fd, temporary = tempfile.mkstemp(prefix=".90-labfy-charge-limit.", dir=CONFIG.parent)
|
|
try:
|
|
os.fchmod(fd, 0o644)
|
|
with os.fdopen(fd, "wb") as output:
|
|
output.write(content)
|
|
output.flush()
|
|
os.fsync(output.fileno())
|
|
os.replace(temporary, CONFIG)
|
|
sync_directory()
|
|
finally:
|
|
if os.path.exists(temporary):
|
|
os.unlink(temporary)
|
|
|
|
|
|
def restore_config(previous: bytes | None) -> None:
|
|
if previous is None:
|
|
try:
|
|
CONFIG.unlink()
|
|
except FileNotFoundError:
|
|
return
|
|
sync_directory()
|
|
else:
|
|
replace_config(previous)
|
|
|
|
|
|
def apply_runtime(value: int) -> bool:
|
|
# TLP's setcharge command is temporary; the atomically replaced file owns boot persistence.
|
|
try:
|
|
result = subprocess.run(
|
|
[TLP, "setcharge", "0", str(value), "BAT1"],
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
timeout=20,
|
|
check=False,
|
|
)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
return False
|
|
return result.returncode == 0
|
|
|
|
|
|
def locked_operation(value: int) -> int:
|
|
try:
|
|
old_value = read_threshold()
|
|
old_config = prior_config()
|
|
except (OSError, ValueError):
|
|
return fail("unavailable")
|
|
|
|
try:
|
|
replace_config(
|
|
f"START_CHARGE_THRESH_BAT1=0\nSTOP_CHARGE_THRESH_BAT1={value}\n".encode("ascii")
|
|
)
|
|
except OSError:
|
|
# Even a directory fsync failure can happen after rename; restore the old bytes.
|
|
try:
|
|
restore_config(old_config)
|
|
except OSError:
|
|
return fail("rollback_failed", 3)
|
|
return fail("unavailable")
|
|
|
|
try:
|
|
applied = apply_runtime(value)
|
|
if applied and read_threshold() == value:
|
|
print(f"SUCCESS {value}")
|
|
return 0
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
# CONTRACT: a failed apply must not leave the new preference for the next boot.
|
|
try:
|
|
restore_config(old_config)
|
|
restored = apply_runtime(old_value) and read_threshold() == old_value
|
|
except (OSError, ValueError):
|
|
restored = False
|
|
return fail("apply_failed" if restored else "rollback_failed", 1 if restored else 3)
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
if len(argv) != 1 or not re.fullmatch(r"[1-9][0-9]{1,2}", argv[0], re.ASCII):
|
|
return fail("invalid_value", 2)
|
|
value = int(argv[0])
|
|
if not 40 <= value <= 100:
|
|
return fail("invalid_value", 2)
|
|
if os.geteuid() != 0:
|
|
return fail("root_required")
|
|
|
|
# One fixed root-owned lock serializes callers; QuickShell also limits itself to one Process.
|
|
try:
|
|
lock_fd = os.open(LOCK, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
|
try:
|
|
metadata = os.fstat(lock_fd)
|
|
if not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != 0:
|
|
return fail("unavailable")
|
|
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
|
return locked_operation(value)
|
|
finally:
|
|
os.close(lock_fd)
|
|
except OSError:
|
|
return fail("unavailable")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|