import asyncio import json import os import signal from typing import Any 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 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: 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, )