ad4fcd1cfd
First-party plugins for Fabled Scryer (https://github.com/bvandeusen/fabledscryer). traefik: Prometheus metrics scraping, stats history, access log parsing (GeoIP optional) unifi: UniFi controller integration — WAN health, devices, clients, DPI, events ups: UPS monitoring via NUT (Network UPS Tools), Ansible shutdown automation Each plugin follows the Fabled Scryer plugin contract: setup(app), get_scheduled_tasks(), get_blueprint() index.yaml lists all three plugins in the catalog format. Populate checksum_sha256 entries once release zips are published via GitHub Releases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
186 lines
6.6 KiB
Python
186 lines
6.6 KiB
Python
# plugins/ups/scheduler.py
|
|
from __future__ import annotations
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
|
|
from fabledscryer.core.scheduler import ScheduledTask
|
|
|
|
if TYPE_CHECKING:
|
|
from quart import Quart
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# In-memory shutdown state.
|
|
# Limitation: resets on app restart. If the app restarts while on battery,
|
|
# the countdown begins again from zero. Given typical UPS runtimes (minutes),
|
|
# this is acceptable. A future improvement could persist this in the DB.
|
|
_on_battery_since: datetime | None = None
|
|
_shutdown_triggered: bool = False
|
|
|
|
|
|
def make_poll_task(app: "Quart") -> ScheduledTask:
|
|
cfg = app.config["PLUGINS"]["ups"]
|
|
interval = int(cfg.get("poll_interval_seconds", 30))
|
|
|
|
async def poll() -> None:
|
|
await _do_poll(app)
|
|
|
|
return ScheduledTask(
|
|
name="ups_poll",
|
|
coro_factory=poll,
|
|
interval_seconds=interval,
|
|
run_on_startup=True,
|
|
)
|
|
|
|
|
|
async def _do_poll(app: "Quart") -> None:
|
|
global _on_battery_since, _shutdown_triggered
|
|
|
|
from .client import NutClient, NutError, parse_status
|
|
from .models import UpsStatus
|
|
from fabledscryer.core.alerts import record_metric
|
|
|
|
cfg = app.config["PLUGINS"]["ups"]
|
|
client = NutClient(
|
|
host=cfg.get("nut_host", "localhost"),
|
|
port=int(cfg.get("nut_port", 3493)),
|
|
username=cfg.get("nut_username", ""),
|
|
password=cfg.get("nut_password", ""),
|
|
)
|
|
|
|
try:
|
|
raw_vars = await client.get_vars(cfg.get("ups_name", "ups"))
|
|
except NutError:
|
|
logger.exception("UPS poll failed")
|
|
return
|
|
|
|
parsed = parse_status(raw_vars)
|
|
scraped_at = datetime.now(timezone.utc)
|
|
|
|
status = UpsStatus(
|
|
scraped_at=scraped_at,
|
|
raw_status=parsed["raw_status"],
|
|
on_battery=parsed["on_battery"],
|
|
low_battery=parsed["low_battery"],
|
|
battery_charge_pct=parsed["battery_charge_pct"],
|
|
battery_runtime_secs=parsed["battery_runtime_secs"],
|
|
battery_voltage=parsed["battery_voltage"],
|
|
load_pct=parsed["load_pct"],
|
|
input_voltage=parsed["input_voltage"],
|
|
output_voltage=parsed["output_voltage"],
|
|
temperature=parsed["temperature"],
|
|
model=parsed["model"],
|
|
manufacturer=parsed["manufacturer"],
|
|
)
|
|
|
|
async with app.db_sessionmaker() as session:
|
|
async with session.begin():
|
|
session.add(status)
|
|
|
|
if parsed["battery_charge_pct"] is not None:
|
|
await record_metric(
|
|
session=session,
|
|
source_module="ups",
|
|
resource_name="ups",
|
|
metric_name="battery_charge_pct",
|
|
value=parsed["battery_charge_pct"],
|
|
)
|
|
await record_metric(
|
|
session=session,
|
|
source_module="ups",
|
|
resource_name="ups",
|
|
metric_name="on_battery",
|
|
value=1.0 if parsed["on_battery"] else 0.0,
|
|
)
|
|
if parsed["battery_runtime_secs"] is not None:
|
|
await record_metric(
|
|
session=session,
|
|
source_module="ups",
|
|
resource_name="ups",
|
|
metric_name="battery_runtime_secs",
|
|
value=float(parsed["battery_runtime_secs"]),
|
|
)
|
|
|
|
# ── Shutdown logic ────────────────────────────────────────────────────────
|
|
shutdown_after = int(cfg.get("shutdown_after_seconds", 0))
|
|
|
|
if parsed["on_battery"]:
|
|
if _on_battery_since is None:
|
|
_on_battery_since = scraped_at
|
|
logger.warning("UPS switched to battery at %s", scraped_at.isoformat())
|
|
|
|
elapsed = (scraped_at - _on_battery_since).total_seconds()
|
|
logger.debug("UPS on battery for %.0f seconds (shutdown_after=%d)", elapsed, shutdown_after)
|
|
|
|
if (
|
|
not _shutdown_triggered
|
|
and shutdown_after > 0
|
|
and elapsed >= shutdown_after
|
|
):
|
|
logger.warning(
|
|
"UPS on battery for %.0fs — triggering shutdown playbook", elapsed
|
|
)
|
|
_shutdown_triggered = True
|
|
asyncio.create_task(_run_shutdown(app, cfg))
|
|
else:
|
|
if _on_battery_since is not None:
|
|
logger.info("UPS back on AC power after %.0fs on battery",
|
|
(scraped_at - _on_battery_since).total_seconds())
|
|
_on_battery_since = None
|
|
_shutdown_triggered = False
|
|
|
|
|
|
async def _run_shutdown(app: "Quart", cfg: dict) -> None:
|
|
"""Locate the configured Ansible playbook and run it via subprocess."""
|
|
source_name = cfg.get("shutdown_source", "")
|
|
playbook_rel = cfg.get("shutdown_playbook", "")
|
|
inventory_name = cfg.get("shutdown_inventory", "hosts")
|
|
|
|
if not source_name or not playbook_rel:
|
|
logger.error(
|
|
"UPS shutdown triggered but shutdown_source or shutdown_playbook not configured"
|
|
)
|
|
return
|
|
|
|
# Resolve source path from Ansible config
|
|
ansible_cfg = app.config.get("ANSIBLE", {})
|
|
sources = ansible_cfg.get("sources", [])
|
|
source = next((s for s in sources if s.get("name") == source_name), None)
|
|
if not source:
|
|
logger.error("UPS shutdown: Ansible source %r not found", source_name)
|
|
return
|
|
|
|
source_path = Path(source["path"])
|
|
playbook_path = source_path / playbook_rel
|
|
inventory_path = source_path / inventory_name
|
|
|
|
if not playbook_path.exists():
|
|
logger.error("UPS shutdown: playbook not found at %s", playbook_path)
|
|
return
|
|
if not inventory_path.exists():
|
|
logger.error("UPS shutdown: inventory not found at %s", inventory_path)
|
|
return
|
|
|
|
logger.warning("UPS shutdown: running %s with inventory %s", playbook_path, inventory_path)
|
|
|
|
try:
|
|
proc = await asyncio.create_subprocess_exec(
|
|
"ansible-playbook", str(playbook_path), "-i", str(inventory_path),
|
|
stdout=asyncio.subprocess.PIPE,
|
|
stderr=asyncio.subprocess.STDOUT,
|
|
cwd=str(source_path),
|
|
)
|
|
assert proc.stdout is not None
|
|
async for line in proc.stdout:
|
|
logger.info("[ups-shutdown] %s", line.decode(errors="replace").rstrip())
|
|
await proc.wait()
|
|
if proc.returncode == 0:
|
|
logger.warning("UPS shutdown playbook completed successfully")
|
|
else:
|
|
logger.error("UPS shutdown playbook exited with code %d", proc.returncode)
|
|
except Exception:
|
|
logger.exception("UPS shutdown playbook execution failed")
|