feat: initial release — traefik, unifi, ups plugins
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>
This commit is contained in:
+141
@@ -0,0 +1,141 @@
|
||||
# plugins/ups/client.py
|
||||
"""Async NUT (Network UPS Tools) client.
|
||||
|
||||
Connects to NUT's TCP port (default 3493) and reads UPS variables.
|
||||
Read-only: no write commands (test, shutdown via NUT) are implemented.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# NUT status flag meanings
|
||||
STATUS_FLAGS = {
|
||||
"OL": "Online",
|
||||
"OB": "On Battery",
|
||||
"LB": "Low Battery",
|
||||
"CHRG": "Charging",
|
||||
"DISCHRG":"Discharging",
|
||||
"BYPASS": "Bypass",
|
||||
"CAL": "Calibrating",
|
||||
"FSD": "Forced Shutdown",
|
||||
"RB": "Replace Battery",
|
||||
"OVER": "Overloaded",
|
||||
"TRIM": "Trimming",
|
||||
"BOOST": "Boosting",
|
||||
}
|
||||
|
||||
|
||||
class NutError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NutClient:
|
||||
"""Minimal async NUT client — connects, reads vars, disconnects."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "localhost",
|
||||
port: int = 3493,
|
||||
username: str = "",
|
||||
password: str = "",
|
||||
timeout: float = 10.0,
|
||||
) -> None:
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.timeout = timeout
|
||||
|
||||
async def get_vars(self, ups_name: str) -> dict[str, str]:
|
||||
"""Open a connection, authenticate if needed, fetch all UPS variables, close."""
|
||||
try:
|
||||
reader, writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(self.host, self.port),
|
||||
timeout=self.timeout,
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as exc:
|
||||
raise NutError(f"Cannot connect to NUT at {self.host}:{self.port}: {exc}") from exc
|
||||
|
||||
try:
|
||||
async def send(cmd: str) -> None:
|
||||
writer.write((cmd + "\n").encode())
|
||||
await writer.drain()
|
||||
|
||||
async def readline() -> str:
|
||||
line = await asyncio.wait_for(reader.readline(), timeout=self.timeout)
|
||||
return line.decode(errors="replace").strip()
|
||||
|
||||
# Optional auth
|
||||
if self.username:
|
||||
await send(f"USERNAME {self.username}")
|
||||
resp = await readline()
|
||||
if not resp.startswith("OK"):
|
||||
raise NutError(f"NUT auth USERNAME rejected: {resp}")
|
||||
if self.password:
|
||||
await send(f"PASSWORD {self.password}")
|
||||
resp = await readline()
|
||||
if not resp.startswith("OK"):
|
||||
raise NutError(f"NUT auth PASSWORD rejected: {resp}")
|
||||
|
||||
# Request variable list
|
||||
await send(f"LIST VAR {ups_name}")
|
||||
|
||||
vars_: dict[str, str] = {}
|
||||
while True:
|
||||
line = await readline()
|
||||
if line.startswith("ERR"):
|
||||
raise NutError(f"NUT error for UPS '{ups_name}': {line}")
|
||||
if line.startswith(f"END LIST VAR {ups_name}"):
|
||||
break
|
||||
if line.startswith(f"VAR {ups_name} "):
|
||||
# Format: VAR <upsname> <varname> "<value>"
|
||||
rest = line[len(f"VAR {ups_name} "):]
|
||||
if " " in rest:
|
||||
varname, raw_val = rest.split(" ", 1)
|
||||
vars_[varname] = raw_val.strip('"')
|
||||
|
||||
await send("LOGOUT")
|
||||
return vars_
|
||||
|
||||
finally:
|
||||
writer.close()
|
||||
try:
|
||||
await writer.wait_closed()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def parse_status(vars_: dict[str, str]) -> dict:
|
||||
"""Extract structured fields from raw NUT variable dict."""
|
||||
raw_status = vars_.get("ups.status", "")
|
||||
flags = set(raw_status.upper().split())
|
||||
|
||||
def _float(key: str) -> float | None:
|
||||
try:
|
||||
return float(vars_[key])
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
|
||||
def _int(key: str) -> int | None:
|
||||
try:
|
||||
return int(float(vars_[key]))
|
||||
except (KeyError, ValueError):
|
||||
return None
|
||||
|
||||
return {
|
||||
"raw_status": raw_status,
|
||||
"on_battery": "OB" in flags,
|
||||
"low_battery": "LB" in flags,
|
||||
"flags": sorted(flags),
|
||||
"battery_charge_pct": _float("battery.charge"),
|
||||
"battery_runtime_secs": _int("battery.runtime"),
|
||||
"battery_voltage": _float("battery.voltage"),
|
||||
"load_pct": _float("ups.load"),
|
||||
"input_voltage": _float("input.voltage"),
|
||||
"output_voltage": _float("output.voltage"),
|
||||
"temperature": _float("ups.temperature"),
|
||||
"model": vars_.get("ups.model") or vars_.get("device.model"),
|
||||
"manufacturer": vars_.get("ups.mfr") or vars_.get("device.mfr"),
|
||||
}
|
||||
Reference in New Issue
Block a user