196 lines
5.5 KiB
Python
196 lines
5.5 KiB
Python
import asyncio
|
|
import json
|
|
import os
|
|
import signal
|
|
from typing import Annotated, Any, cast
|
|
|
|
from fastapi import APIRouter, Form
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
from app.plugins.base import Plugin
|
|
from app.render import render
|
|
|
|
router = APIRouter(prefix="/api/plugins/sleep", tags=["plugins"])
|
|
|
|
WHO = "Dashboard (sleep-inhibit)"
|
|
WHY = "dashboard: keep system awake"
|
|
BLOCK_MODES = ("block", "block-weak")
|
|
|
|
_holder: asyncio.subprocess.Process | None = None
|
|
_toggle_lock = asyncio.Lock()
|
|
|
|
|
|
async def _list() -> tuple[list[dict[str, Any]], str]:
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"systemd-inhibit", "--json=short", "--list",
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.PIPE,
|
|
)
|
|
except OSError as e:
|
|
return [], str(e)[:200]
|
|
try:
|
|
out, err = await asyncio.wait_for(proc.communicate(), 5)
|
|
except TimeoutError:
|
|
try:
|
|
_ = proc.kill()
|
|
except ProcessLookupError:
|
|
pass
|
|
return [], "systemd-inhibit timed out"
|
|
if proc.returncode != 0:
|
|
return [], (err.decode(errors="replace").strip() or f"systemd-inhibit failed (rc={proc.returncode})")[:200]
|
|
try:
|
|
data = json.loads(out.decode(errors="replace"))
|
|
except ValueError:
|
|
return [], "could not parse systemd-inhibit output"
|
|
if not isinstance(data, list):
|
|
return [], "unexpected systemd-inhibit output"
|
|
items: list[dict[str, Any]] = [e for e in cast("list[Any]", data) if isinstance(e, dict)]
|
|
return items, ""
|
|
|
|
|
|
def _verdict(inhibitors: list[dict[str, Any]]) -> str:
|
|
for e in inhibitors:
|
|
whats = str(e.get("what", "")).split(":")
|
|
if "sleep" in whats and e.get("mode") in BLOCK_MODES:
|
|
return "blocked"
|
|
return "ok"
|
|
|
|
|
|
def _rows(inhibitors: list[dict[str, Any]]) -> list[dict[str, str | bool]]:
|
|
rows: list[dict[str, str | bool]] = []
|
|
for e in inhibitors:
|
|
mode = str(e.get("mode", ""))
|
|
if mode not in BLOCK_MODES:
|
|
continue
|
|
user = str(e.get("user", ""))
|
|
pid = e.get("pid")
|
|
if isinstance(pid, int) and pid > 0:
|
|
proc = f"{user} · {pid}" if user else str(pid)
|
|
else:
|
|
proc = user
|
|
rows.append({
|
|
"who": str(e.get("who", "?")),
|
|
"proc": proc,
|
|
"what": str(e.get("what", "")),
|
|
"why": str(e.get("why", "")),
|
|
"mode": mode,
|
|
"own": e.get("who") == WHO,
|
|
})
|
|
return rows
|
|
|
|
|
|
def _reap_dead_holder() -> None:
|
|
global _holder
|
|
if _holder is not None and _holder.returncode is not None:
|
|
_holder = None
|
|
|
|
|
|
def _context(inhibitors: list[dict[str, Any]], error: str, message: str = "") -> dict[str, Any]:
|
|
_reap_dead_holder()
|
|
return {
|
|
"inhibitors": _rows(inhibitors),
|
|
"verdict": _verdict(inhibitors),
|
|
"message": message,
|
|
"error": error,
|
|
"holding": _holder is not None,
|
|
}
|
|
|
|
|
|
async def _state(message: str = "", error: str = "") -> str:
|
|
inhibitors, err = await _list()
|
|
if error:
|
|
err = error
|
|
return render("plugins/sleep_state.html", **_context(inhibitors, err, message))
|
|
|
|
|
|
async def _skeleton(message: str = "", error: str = "") -> str:
|
|
inhibitors, err = await _list()
|
|
if error:
|
|
err = error
|
|
return render("plugins/sleep_skeleton.html", **_context(inhibitors, err, message))
|
|
|
|
|
|
async def _acquire() -> str:
|
|
global _holder
|
|
try:
|
|
_holder = await asyncio.create_subprocess_exec(
|
|
"systemd-inhibit",
|
|
"--what=sleep",
|
|
"--mode=block",
|
|
f"--who={WHO}",
|
|
f"--why={WHY}",
|
|
"sleep", "infinity",
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
)
|
|
except OSError as e:
|
|
return str(e)[:200]
|
|
return ""
|
|
|
|
|
|
async def _release() -> None:
|
|
global _holder
|
|
p, _holder = _holder, None
|
|
if p is None:
|
|
return
|
|
try:
|
|
os.killpg(p.pid, signal.SIGTERM)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
try:
|
|
_ = await asyncio.wait_for(p.wait(), 3)
|
|
except TimeoutError:
|
|
try:
|
|
os.killpg(p.pid, signal.SIGKILL)
|
|
except (ProcessLookupError, PermissionError):
|
|
pass
|
|
_ = await p.wait()
|
|
|
|
|
|
@router.get("/state")
|
|
async def state():
|
|
return HTMLResponse(await _state())
|
|
|
|
|
|
@router.post("/toggle")
|
|
async def toggle(on: Annotated[str | None, Form()] = None):
|
|
async with _toggle_lock:
|
|
if on and _holder is None:
|
|
err = await _acquire()
|
|
if err:
|
|
return HTMLResponse(await _skeleton(error=err))
|
|
return HTMLResponse(await _skeleton(message="inhibiting sleep"))
|
|
if not on and _holder is not None:
|
|
await _release()
|
|
return HTMLResponse(await _skeleton(message="sleep inhibition released"))
|
|
return HTMLResponse(await _skeleton())
|
|
|
|
|
|
async def _open() -> None:
|
|
inhibitors, _err = await _list()
|
|
for e in inhibitors:
|
|
if e.get("who") != WHO:
|
|
continue
|
|
pid = e.get("pid")
|
|
if not isinstance(pid, int) or pid <= 0:
|
|
continue
|
|
try:
|
|
_ = os.kill(pid, signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass
|
|
|
|
|
|
async def _close() -> None:
|
|
await _release()
|
|
|
|
|
|
plugin = Plugin(
|
|
id="sleep",
|
|
title="Sleep inhibitors",
|
|
description="Active block-mode systemd inhibitor locks.",
|
|
skeleton_fn=_skeleton,
|
|
open_fn=_open,
|
|
close_fn=_close,
|
|
)
|