dashboard/app/plugins/sleep.py

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,
)