Initial pump monitor with Coolify deployment

This commit is contained in:
2026-09-06 10:37:34 +02:00
commit 9d7179308f
16 changed files with 1539 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
from __future__ import annotations
import base64
import csv
import io
import logging
import secrets
from contextlib import asynccontextmanager
from datetime import UTC, datetime, time, timedelta
from fastapi import FastAPI, Query, Request
from fastapi.responses import HTMLResponse, PlainTextResponse, Response
from .config import Settings
from .monitor import TapoMonitor
from .storage import Storage, from_db_time, to_db_time
from .web import DASHBOARD_HTML
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
settings = Settings.from_env()
storage = Storage(settings.database_path)
monitor = TapoMonitor(settings, storage)
@asynccontextmanager
async def lifespan(_: FastAPI):
storage.open()
await monitor.start()
try:
yield
finally:
await monitor.stop()
storage.close()
app = FastAPI(title="Tapo Pump Monitor", docs_url=None, redoc_url=None, lifespan=lifespan)
@app.middleware("http")
async def protect_dashboard(request: Request, call_next):
if request.url.path == "/health" or not settings.dashboard_password:
return await call_next(request)
header = request.headers.get("Authorization", "")
valid = False
if header.startswith("Basic "):
try:
decoded = base64.b64decode(header[6:], validate=True).decode("utf-8")
username, password = decoded.split(":", 1)
valid = secrets.compare_digest(username, settings.dashboard_username) and secrets.compare_digest(
password, settings.dashboard_password
)
except (ValueError, UnicodeDecodeError):
valid = False
if not valid:
return Response(status_code=401, headers={"WWW-Authenticate": 'Basic realm="Pump monitor"'})
return await call_next(request)
def local_day_bounds() -> tuple[datetime, datetime]:
now_local = datetime.now(settings.timezone)
start_local = datetime.combine(now_local.date(), time.min, tzinfo=settings.timezone)
return start_local.astimezone(UTC), (start_local + timedelta(days=1)).astimezone(UTC)
@app.get("/", response_class=HTMLResponse)
async def dashboard() -> str:
return DASHBOARD_HTML
@app.get("/health")
async def health() -> dict:
return {"service": "ok", "plug_state": monitor.state}
@app.get("/api/status")
async def api_status() -> dict:
day_start, day_end = local_day_bounds()
return {
"monitor": monitor.snapshot(),
"today": storage.summary_between(day_start, day_end),
"settings": {
"start_watts": settings.start_watts,
"stop_watts": settings.stop_watts,
"poll_interval_seconds": settings.poll_interval_seconds,
"timezone": settings.timezone_name,
},
"security": {"dashboard_auth_enabled": bool(settings.dashboard_password)},
}
@app.get("/api/cycles")
async def api_cycles(limit: int = Query(default=100, ge=1, le=1000)) -> dict:
return {"cycles": storage.recent_cycles(limit)}
@app.get("/api/history")
async def api_history(hours: int = Query(default=24, ge=1, le=168)) -> dict:
window_end = datetime.now(UTC)
window_start = window_end - timedelta(hours=hours)
cycles = storage.cycles_between(window_start, window_end)
preserve_ranges = [
(
from_db_time(cycle["started_at"]),
from_db_time(cycle["ended_at"]) if cycle["ended_at"] else window_end,
)
for cycle in cycles
]
return {
"window_start": to_db_time(window_start),
"window_end": to_db_time(window_end),
"samples": storage.sample_history(
window_start,
window_end,
max_points=800,
preserve_ranges=preserve_ranges,
),
"cycles": cycles,
}
@app.get("/api/cycles.csv")
async def export_cycles(days: int = Query(default=30, ge=1, le=3650)) -> Response:
end = datetime.now(UTC)
rows = storage.cycles_between(end - timedelta(days=days), end)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(
[
"started_at_utc",
"ended_at_utc",
"duration_seconds",
"average_watts",
"maximum_watts",
"energy_wh",
"partial_cycle",
]
)
for row in reversed(rows):
writer.writerow(
[
row["started_at"],
row["ended_at"],
round(float(row["duration_seconds"] or 0), 3),
round(float(row["average_watts"] or 0), 3),
round(float(row["maximum_watts"] or 0), 3),
round(float(row["energy_wh"] or 0), 5),
bool(row["incomplete_start"] or row["incomplete_end"]),
]
)
return PlainTextResponse(
output.getvalue(),
media_type="text/csv",
headers={"Content-Disposition": 'attachment; filename="pump-cycles.csv"'},
)