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