"""Per-UPS data APIs: status, history, metric, events, energy, health, battery health, export, debug, tiles.""" import json import time import orjson from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from ..apc_cli import APCStatusError, fetch_status from ..auth import require_session, require_session_and_csrf from ..config import load_config from ..exports import export_energy_csv, export_events_csv, export_history_csv from ..storage import get_history, get_latest, get_redis router = APIRouter(prefix="/api/ups") @router.get("") async def list_ups(user=Depends(require_session)): cfg = load_config() return [{"name": u.name, "host": u.host, "port": u.port} for u in cfg.ups] @router.get("/fleet/overview") async def fleet_overview(user=Depends(require_session)): """Aggregate summary across all UPS.""" cfg = load_config() r = get_redis() total = len(cfg.ups) counts = {"online": 0, "on_battery": 0, "warning": 0, "offline": 0, "unknown": 0} total_watts = 0.0 min_timeleft = None rows = [] for ups in cfg.ups: snap = r.hgetall(f"ups:snap:{ups.name}") or {} is_offline = bool(r.get(f"ups:health:offline:{ups.name}")) status = str(snap.get("STATUS", "")).upper() state = "unknown" if is_offline: state = "offline" elif "ONBATT" in status: state = "on_battery" elif "ONLINE" in status: state = "online" counts[state] = counts.get(state, 0) + 1 try: w = float(snap.get("DERIVED_WATTS", 0) or 0) total_watts += w except ValueError: pass try: tl = float(str(snap.get("TIMELEFT", "")).split()[0]) if min_timeleft is None or tl < min_timeleft: min_timeleft = tl except Exception: pass rows.append({"name": ups.name, "state": state, "status": status}) return { "total": total, "counts": counts, "total_watts": round(total_watts, 1), "min_timeleft_minutes": min_timeleft, "rows": rows, } @router.get("/{ups_name}") async def ups_status(ups_name: str, user=Depends(require_session)): snap = await get_latest(ups_name) return snap or {"error": "not found"} @router.get("/{ups_name}/history") async def ups_history(ups_name: str, user=Depends(require_session)): return await get_history(ups_name) @router.get("/{ups_name}/metric/{metric}") async def metric_history( ups_name: str, metric: str, limit: int = 120, user=Depends(require_session) ): limit = max(1, min(limit, 500)) all_hist = await get_history(ups_name) recent = all_hist[-limit:] out = [] for item in recent: data = item.get("data", {}) raw_val = data.get(metric) if raw_val is None: continue try: val = float(str(raw_val).split()[0]) except Exception: continue out.append({"ts": item.get("ts"), "value": val}) return out @router.get("/{ups_name}/events") async def ups_events(ups_name: str, user=Depends(require_session)): r = get_redis() raw = r.lrange(f"ups:event:list:{ups_name}", 0, 99) parsed = [] for item in raw: if "|" in item: try: ts_s, kind, rest = item.split("|", 2) parsed.append({"ts": int(ts_s), "type": kind, "detail": rest}) continue except Exception: pass parsed.append({"raw": item}) return parsed @router.get("/{ups_name}/energy") async def ups_energy(ups_name: str, user=Depends(require_session)): r = get_redis() day_str = time.strftime("%Y%m%d") key = f"ups:energy:{ups_name}:{day_str}" watt_seconds = r.get(key) cfg = load_config() rate = cfg.ui.energy_cost_per_kwh if hasattr(cfg.ui, "energy_cost_per_kwh") else 0.0 if watt_seconds: try: ws = float(watt_seconds) kwh = ws / 3600.0 / 1000.0 return {"kwh_today": round(kwh, 4), "cost_today": round(kwh * rate, 4)} except ValueError: pass return {"kwh_today": None, "cost_today": None} @router.get("/{ups_name}/watts_per_minute") async def ups_watts_per_minute(ups_name: str, user=Depends(require_session)): r = get_redis() raw = r.lrange(f"ups:watts:permin:{ups_name}", 0, 1440) out = [] for item in raw: if "|" in item: minute, avg = item.split("|", 1) try: out.append({"minute": minute, "avg_watts": float(avg)}) except ValueError: continue out.reverse() return out @router.get("/{ups_name}/health") async def ups_health(ups_name: str, user=Depends(require_session)): r = get_redis() alert_raw = r.lrange(f"ups:alerts:recent:{ups_name}", 0, 19) alerts = [] for a in alert_raw: parts = a.split("|", 2) if len(parts) == 3: try: alerts.append({"ts": int(parts[0]), "severity": parts[1], "msg": parts[2]}) continue except ValueError: pass if "|" in a: ts_s, msg = a.split("|", 1) try: alerts.append({"ts": int(ts_s), "msg": msg}) continue except ValueError: pass alerts.append({"raw": a}) dev_samples = r.lrange(f"ups:volt:dev:samples:{ups_name}", 0, 49) dev_vals = [] for d in dev_samples: try: dev_vals.append(float(d)) except ValueError: continue dev_avg = sum(dev_vals) / len(dev_vals) if dev_vals else None dev_max = max(dev_vals) if dev_vals else None last_ok = r.get(f"ups:health:last_ok:{ups_name}") is_offline = bool(r.get(f"ups:health:offline:{ups_name}")) fail_count = r.get(f"ups:health:fail_count:{ups_name}") events = r.lrange(f"ups:event:list:{ups_name}", 0, 200) now = int(time.time()) onbatt_hour = 0 for ev in events: parts = ev.split("|") if len(parts) >= 3: try: ts_e = int(parts[0]) except ValueError: continue if now - ts_e > 3600: continue if parts[1] == "STATUS" and "ONBATT" in parts[2]: onbatt_hour += 1 return { "online": not is_offline, "last_ok_ts": int(last_ok) if last_ok else None, "fail_count": int(fail_count) if fail_count else 0, "alerts": alerts, "voltage_deviation": { "avg_pct": round(dev_avg, 2) if dev_avg is not None else None, "max_pct": round(dev_max, 2) if dev_max is not None else None, "samples": len(dev_vals), }, "onbatt_last_hour": onbatt_hour, } @router.get("/{ups_name}/battery_health") async def battery_health(ups_name: str, user=Depends(require_session)): """Return battery-health trend from sampled history.""" r = get_redis() raw = r.lrange(f"ups:battery:history:{ups_name}", 0, -1) samples = [] for item in raw: try: samples.append(json.loads(item)) except Exception: continue samples.sort(key=lambda s: s.get("ts", 0)) # Estimate runtime-at-full-charge: TIMELEFT * (100 / BCHARGE) when BCHARGE>0 normalized = [] for s in samples: bc = s.get("bcharge") tl = s.get("timeleft") if bc and tl and bc > 10: normalized.append({"ts": s["ts"], "est_full_runtime_min": tl * 100.0 / bc}) slope_per_day = None decline_pct_14d = None if len(normalized) >= 10: first = normalized[0] last = normalized[-1] span_days = max(1e-6, (last["ts"] - first["ts"]) / 86400.0) slope_per_day = (last["est_full_runtime_min"] - first["est_full_runtime_min"]) / span_days # Compute 14d decline as % of initial runtime cutoff_14d = last["ts"] - 14 * 86400 older = [n for n in normalized if n["ts"] <= cutoff_14d] if older: baseline = older[0]["est_full_runtime_min"] if baseline > 0: decline_pct_14d = ( (baseline - last["est_full_runtime_min"]) / baseline * 100.0 ) return { "samples": len(samples), "estimated_full_runtime_samples": normalized[-200:], "slope_per_day": round(slope_per_day, 3) if slope_per_day is not None else None, "decline_pct_14d": round(decline_pct_14d, 2) if decline_pct_14d is not None else None, } @router.get("/{ups_name}/export") async def export_ups( ups_name: str, format: str = Query("csv"), since_days: int = Query(7, ge=1, le=30), kind: str = Query("history"), user=Depends(require_session), ): if format != "csv": raise HTTPException(status_code=400, detail="Only csv format is supported") if kind == "history": gen = export_history_csv(ups_name, since_days) filename = f"{ups_name}_history_{since_days}d.csv" elif kind == "events": gen = export_events_csv(ups_name, since_days) filename = f"{ups_name}_events_{since_days}d.csv" elif kind == "energy": gen = export_energy_csv(ups_name, since_days) filename = f"{ups_name}_energy_{since_days}d.csv" else: raise HTTPException(status_code=400, detail="kind must be history|events|energy") return StreamingResponse( gen, media_type="text/csv", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.get("/{ups_name}/debug") async def ups_debug(ups_name: str, user=Depends(require_session)): cfg = load_config() target = next((u for u in cfg.ups if u.name == ups_name), None) if not target: raise HTTPException(status_code=404, detail="UPS not found") try: return await fetch_status(target.host, target.port) except APCStatusError as e: raise HTTPException(status_code=502, detail=str(e)) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @router.get("/{ups_name}/ui_tiles") async def get_ups_ui_tiles(ups_name: str, user=Depends(require_session)): r = get_redis() raw = r.get(f"ups:ui:tiles:{ups_name}") default = {"types": {}, "order": [], "hidden": [], "custom": [], "positions": {}} if not raw: return default try: data = orjson.loads(raw) return { "types": data.get("types", {}), "order": data.get("order", []), "hidden": data.get("hidden", []), "custom": data.get("custom", []), "positions": data.get("positions", {}), } except Exception: return default @router.post("/{ups_name}/ui_tiles") async def save_ups_ui_tiles( ups_name: str, payload: dict, user=Depends(require_session_and_csrf) ): types = payload.get("types") if isinstance(payload.get("types"), dict) else {} order = payload.get("order") if isinstance(payload.get("order"), list) else [] hidden = payload.get("hidden") if isinstance(payload.get("hidden"), list) else [] custom = payload.get("custom") if isinstance(payload.get("custom"), list) else [] positions = payload.get("positions") if isinstance(payload.get("positions"), dict) else {} norm_custom = [] for c in custom or []: if not isinstance(c, dict): continue metric = c.get("metric") chart = c.get("chart") if not metric or not chart: continue norm_custom.append({ "id": c.get("id") or "", "metric": metric, "chart": chart, "source": c.get("source", "live"), }) doc = { "types": types, "order": order, "hidden": hidden, "custom": norm_custom, "positions": positions, "saved_ts": int(time.time()), } r = get_redis() r.set(f"ups:ui:tiles:{ups_name}", orjson.dumps(doc)) return {"message": "saved", "count_custom": len(norm_custom)} @router.delete("/{ups_name}/ui_tiles") async def clear_ups_ui_tiles(ups_name: str, user=Depends(require_session_and_csrf)): r = get_redis() r.delete(f"ups:ui:tiles:{ups_name}") return {"message": "cleared"}