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
+1
View File
@@ -0,0 +1 @@
"""Tapo P110 pump-cycle monitor."""
+84
View File
@@ -0,0 +1,84 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def _float_env(name: str, default: float, minimum: float) -> float:
raw = os.getenv(name, str(default))
try:
value = float(raw)
except ValueError as exc:
raise ValueError(f"{name} must be a number") from exc
if value < minimum:
raise ValueError(f"{name} must be at least {minimum}")
return value
def _int_env(name: str, default: int, minimum: int) -> int:
raw = os.getenv(name, str(default))
try:
value = int(raw)
except ValueError as exc:
raise ValueError(f"{name} must be an integer") from exc
if value < minimum:
raise ValueError(f"{name} must be at least {minimum}")
return value
@dataclass(frozen=True)
class Settings:
tapo_host: str
tapo_username: str
tapo_password: str
timezone_name: str
poll_interval_seconds: float
start_watts: float
stop_watts: float
confirm_samples: int
max_gap_seconds: float
sample_retention_days: int
database_path: str
dashboard_username: str
dashboard_password: str
@classmethod
def from_env(cls) -> "Settings":
timezone_name = os.getenv("TZ", "Europe/Prague")
try:
ZoneInfo(timezone_name)
except ZoneInfoNotFoundError as exc:
raise ValueError(f"TZ is not a known timezone: {timezone_name}") from exc
start_watts = _float_env("START_WATTS", 100.0, 0.1)
stop_watts = _float_env("STOP_WATTS", 30.0, 0.0)
if stop_watts >= start_watts:
raise ValueError("STOP_WATTS must be lower than START_WATTS")
poll_interval = _float_env("POLL_INTERVAL_SECONDS", 2.0, 0.5)
max_gap = _float_env("MAX_GAP_SECONDS", max(20.0, poll_interval * 5), poll_interval * 2)
return cls(
tapo_host=os.getenv("TAPO_HOST", "").strip(),
tapo_username=os.getenv("TAPO_USERNAME", "").strip(),
tapo_password=os.getenv("TAPO_PASSWORD", ""),
timezone_name=timezone_name,
poll_interval_seconds=poll_interval,
start_watts=start_watts,
stop_watts=stop_watts,
confirm_samples=_int_env("CONFIRM_SAMPLES", 2, 1),
max_gap_seconds=max_gap,
sample_retention_days=_int_env("SAMPLE_RETENTION_DAYS", 30, 1),
database_path=os.getenv("DATABASE_PATH", "/data/pump-monitor.sqlite3"),
dashboard_username=os.getenv("DASHBOARD_USERNAME", "water-monitor").strip(),
dashboard_password=os.getenv("DASHBOARD_PASSWORD", ""),
)
@property
def credentials_configured(self) -> bool:
return bool(self.tapo_username and self.tapo_password)
@property
def timezone(self) -> ZoneInfo:
return ZoneInfo(self.timezone_name)
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import Any
from .storage import Storage
@dataclass(frozen=True)
class PowerSample:
sampled_at: datetime
watts: float
class CycleDetector:
"""Detect pump runs using two thresholds and consecutive-sample confirmation."""
def __init__(self, storage: Storage, start_watts: float, stop_watts: float, confirm_samples: int):
self.storage = storage
self.start_watts = start_watts
self.stop_watts = stop_watts
self.confirm_samples = confirm_samples
self.running = False
self.cycle_id: int | None = None
self.pending_start: list[PowerSample] = []
self.pending_stop: list[PowerSample] = []
self.first_observation = True
self.pending_incomplete_start = False
self.last_sample_at: datetime | None = None
def process(self, sample: PowerSample) -> list[dict[str, Any]]:
events: list[dict[str, Any]] = []
self.last_sample_at = sample.sampled_at
if not self.running:
if sample.watts >= self.start_watts:
if not self.pending_start:
self.pending_incomplete_start = self.first_observation
self.pending_start.append(sample)
if len(self.pending_start) >= self.confirm_samples:
started_at = self.pending_start[0].sampled_at
self.cycle_id = self.storage.start_cycle(started_at, self.pending_incomplete_start)
self.running = True
events.append(
{
"type": "started",
"cycle_id": self.cycle_id,
"at": started_at,
"incomplete_start": self.pending_incomplete_start,
}
)
self.pending_start.clear()
self.pending_incomplete_start = False
else:
self.pending_start.clear()
self.pending_incomplete_start = False
else:
if sample.watts <= self.stop_watts:
self.pending_stop.append(sample)
if len(self.pending_stop) >= self.confirm_samples:
ended_at = self.pending_stop[0].sampled_at
cycle_id = self.cycle_id
if cycle_id is None:
raise RuntimeError("Running detector has no cycle id")
cycle = self.storage.finish_cycle(cycle_id, ended_at)
events.append({"type": "stopped", "cycle": cycle, "at": ended_at})
self.running = False
self.cycle_id = None
self.pending_stop.clear()
else:
self.pending_stop.clear()
self.first_observation = False
return events
def interrupt(self, ended_at: datetime | None = None) -> dict[str, Any] | None:
event = None
if self.running and self.cycle_id is not None:
final_time = ended_at or self.last_sample_at
if final_time is not None:
cycle = self.storage.finish_cycle(self.cycle_id, final_time, incomplete_end=True)
event = {"type": "interrupted", "cycle": cycle, "at": final_time}
self.running = False
self.cycle_id = None
self.pending_start.clear()
self.pending_stop.clear()
self.pending_incomplete_start = False
self.first_observation = True
self.last_sample_at = None
return event
+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"'},
)
+186
View File
@@ -0,0 +1,186 @@
from __future__ import annotations
import asyncio
import logging
import time
from datetime import UTC, datetime
from typing import Any
from kasa import Discover, Module
from .config import Settings
from .detector import CycleDetector, PowerSample
from .storage import Storage, to_db_time
logger = logging.getLogger("pump-monitor")
class TapoMonitor:
def __init__(self, settings: Settings, storage: Storage):
self.settings = settings
self.storage = storage
self.detector = CycleDetector(
storage,
start_watts=settings.start_watts,
stop_watts=settings.stop_watts,
confirm_samples=settings.confirm_samples,
)
self.device: Any = None
self.task: asyncio.Task[None] | None = None
self.state = "starting"
self.error: str | None = None
self.watts: float | None = None
self.voltage: float | None = None
self.current_amps: float | None = None
self.socket_on: bool | None = None
self.last_success: datetime | None = None
self.started_at = datetime.now(UTC)
self._last_error_key: str | None = None
async def start(self) -> None:
stale_count = self.storage.close_open_cycles()
if stale_count:
logger.warning("Closed %d interrupted cycle(s) left open by a previous run", stale_count)
self.storage.cleanup_samples(self.settings.sample_retention_days)
self.task = asyncio.create_task(self._run(), name="tapo-power-poller")
async def stop(self) -> None:
if self.task is not None:
self.task.cancel()
try:
await self.task
except asyncio.CancelledError:
pass
self.task = None
self.detector.interrupt(self.last_success)
await self._disconnect()
async def _connect(self) -> None:
if not self.settings.tapo_host:
raise RuntimeError("Tapo host has not been configured")
if not self.settings.credentials_configured:
raise RuntimeError("Tapo credentials have not been configured")
self.device = await Discover.discover_single(
self.settings.tapo_host,
username=self.settings.tapo_username,
password=self.settings.tapo_password,
discovery_timeout=5,
timeout=5,
)
if self.device is None:
raise RuntimeError("Tapo plug was not found at the configured address")
await self.device.update()
if Module.Energy not in self.device.modules:
raise RuntimeError("The connected Tapo device does not expose energy monitoring")
logger.info("Connected to %s (%s)", self.device.alias or "Tapo plug", self.device.model or "unknown model")
async def _disconnect(self) -> None:
if self.device is not None:
try:
await self.device.disconnect()
except Exception:
pass
self.device = None
async def _run(self) -> None:
reconnect_delay = 2.0
cleanup_at = time.monotonic()
while True:
iteration_started = time.monotonic()
try:
if self.device is None:
await self._connect()
await self.device.update()
energy = self.device.modules[Module.Energy]
watts = float(energy.current_consumption)
voltage = self._optional_float(energy.voltage)
current_amps = self._optional_float(energy.current)
sampled_at = datetime.now(UTC)
if self.last_success is not None:
gap = (sampled_at - self.last_success).total_seconds()
if gap > self.settings.max_gap_seconds:
event = self.detector.interrupt(self.last_success)
if event:
logger.warning("Cycle interrupted by a %.1f-second monitoring gap", gap)
socket_on = bool(self.device.is_on)
self.storage.record_sample(sampled_at, watts, voltage, current_amps, socket_on)
events = self.detector.process(PowerSample(sampled_at, watts))
for event in events:
if event["type"] == "started":
logger.info("Pump started at %s", to_db_time(event["at"]))
elif event["type"] == "stopped":
cycle = event["cycle"]
logger.info("Pump stopped after %.1f seconds", cycle["duration_seconds"])
self.watts = watts
self.voltage = voltage
self.current_amps = current_amps
self.socket_on = socket_on
self.last_success = sampled_at
self.state = "running" if self.detector.running else "idle"
self.error = None
self._last_error_key = None
reconnect_delay = 2.0
if time.monotonic() >= cleanup_at:
self.storage.cleanup_samples(self.settings.sample_retention_days)
cleanup_at = time.monotonic() + 86400
except asyncio.CancelledError:
raise
except Exception as exc:
error_key = f"{type(exc).__name__}:{exc}"
if error_key != self._last_error_key:
logger.warning("Cannot read Tapo power data: %s", exc)
self._last_error_key = error_key
tapo_configured = bool(self.settings.tapo_host and self.settings.credentials_configured)
self.state = "configuration_required" if not tapo_configured else "offline"
self.error = self._friendly_error(exc)
self.watts = None
self.voltage = None
self.current_amps = None
self.socket_on = None
if self.last_success is not None:
gap = (datetime.now(UTC) - self.last_success).total_seconds()
if gap > self.settings.max_gap_seconds and self.detector.running:
self.detector.interrupt(self.last_success)
await self._disconnect()
await asyncio.sleep(reconnect_delay)
reconnect_delay = min(30.0, reconnect_delay * 1.7)
elapsed = time.monotonic() - iteration_started
await asyncio.sleep(max(0.1, self.settings.poll_interval_seconds - elapsed))
@staticmethod
def _optional_float(value: Any) -> float | None:
return None if value is None else float(value)
@staticmethod
def _friendly_error(exc: Exception) -> str:
text = str(exc)
lowered = text.lower()
if "tpap" in lowered and "unsupported device" in lowered:
return (
"This P110 firmware requires Third-Party Compatibility. "
"In the Tapo app, open Me > Third-Party Services > Third-Party Compatibility and enable it."
)
if "host has not been configured" in lowered:
return "The plug address has not been configured. Add TAPO_HOST in Coolify."
if "authentication" in lowered or "credentials" in lowered or "login" in lowered:
return "Tapo authentication failed. Check the Coolify username and password secrets."
if "not found" in lowered or "timed out" in lowered or "timeout" in lowered:
return "The plug is not reachable from the server. Check its address and LAN access."
return text[:240] or type(exc).__name__
def snapshot(self) -> dict[str, Any]:
return {
"state": self.state,
"error": self.error,
"watts": self.watts,
"voltage": self.voltage,
"current_amps": self.current_amps,
"socket_on": self.socket_on,
"last_success": to_db_time(self.last_success) if self.last_success else None,
"monitor_started_at": to_db_time(self.started_at),
}
+245
View File
@@ -0,0 +1,245 @@
from __future__ import annotations
import math
import sqlite3
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
def utc_now() -> datetime:
return datetime.now(UTC)
def to_db_time(value: datetime) -> str:
return value.astimezone(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
def from_db_time(value: str) -> datetime:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
class Storage:
def __init__(self, path: str):
self.path = path
self.db: sqlite3.Connection | None = None
def open(self) -> None:
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
self.db = sqlite3.connect(self.path, check_same_thread=False)
self.db.row_factory = sqlite3.Row
self.db.execute("PRAGMA journal_mode=WAL")
self.db.execute("PRAGMA synchronous=NORMAL")
self.db.executescript(
"""
CREATE TABLE IF NOT EXISTS samples (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sampled_at TEXT NOT NULL,
watts REAL NOT NULL,
voltage REAL,
current_amps REAL,
socket_on INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_samples_at ON samples(sampled_at);
CREATE TABLE IF NOT EXISTS cycles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at TEXT NOT NULL,
ended_at TEXT,
duration_seconds REAL,
average_watts REAL,
maximum_watts REAL,
energy_wh REAL,
sample_count INTEGER,
incomplete_start INTEGER NOT NULL DEFAULT 0,
incomplete_end INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_cycles_started ON cycles(started_at);
"""
)
self.db.commit()
def close(self) -> None:
if self.db is not None:
self.db.close()
self.db = None
def _conn(self) -> sqlite3.Connection:
if self.db is None:
raise RuntimeError("Database is not open")
return self.db
def record_sample(
self,
sampled_at: datetime,
watts: float,
voltage: float | None,
current_amps: float | None,
socket_on: bool,
) -> None:
db = self._conn()
db.execute(
"INSERT INTO samples(sampled_at, watts, voltage, current_amps, socket_on) VALUES (?, ?, ?, ?, ?)",
(to_db_time(sampled_at), watts, voltage, current_amps, int(socket_on)),
)
db.commit()
def start_cycle(self, started_at: datetime, incomplete_start: bool = False) -> int:
db = self._conn()
cursor = db.execute(
"INSERT INTO cycles(started_at, incomplete_start) VALUES (?, ?)",
(to_db_time(started_at), int(incomplete_start)),
)
db.commit()
return int(cursor.lastrowid)
def finish_cycle(self, cycle_id: int, ended_at: datetime, incomplete_end: bool = False) -> dict[str, Any]:
db = self._conn()
cycle = db.execute("SELECT * FROM cycles WHERE id = ?", (cycle_id,)).fetchone()
if cycle is None:
raise RuntimeError(f"Cycle {cycle_id} does not exist")
started_at = from_db_time(cycle["started_at"])
if ended_at < started_at:
ended_at = started_at
start_text = to_db_time(started_at)
end_text = to_db_time(ended_at)
rows = db.execute(
"SELECT sampled_at, watts FROM samples WHERE sampled_at >= ? AND sampled_at < ? ORDER BY sampled_at",
(start_text, end_text),
).fetchall()
values = [(from_db_time(row["sampled_at"]), float(row["watts"])) for row in rows]
duration = max(0.0, (ended_at - started_at).total_seconds())
average = sum(watts for _, watts in values) / len(values) if values else 0.0
maximum = max((watts for _, watts in values), default=0.0)
energy_wh = self._integrate_energy(values, ended_at)
db.execute(
"""
UPDATE cycles
SET ended_at = ?, duration_seconds = ?, average_watts = ?, maximum_watts = ?,
energy_wh = ?, sample_count = ?, incomplete_end = ?
WHERE id = ?
""",
(end_text, duration, average, maximum, energy_wh, len(values), int(incomplete_end), cycle_id),
)
db.commit()
return self.get_cycle(cycle_id)
@staticmethod
def _integrate_energy(values: list[tuple[datetime, float]], ended_at: datetime) -> float:
if not values:
return 0.0
watt_seconds = 0.0
for index, (sampled_at, watts) in enumerate(values):
segment_end = values[index + 1][0] if index + 1 < len(values) else ended_at
seconds = max(0.0, (segment_end - sampled_at).total_seconds())
watt_seconds += watts * seconds
return watt_seconds / 3600.0
def get_cycle(self, cycle_id: int) -> dict[str, Any]:
row = self._conn().execute("SELECT * FROM cycles WHERE id = ?", (cycle_id,)).fetchone()
if row is None:
raise RuntimeError(f"Cycle {cycle_id} does not exist")
return dict(row)
def close_open_cycles(self) -> int:
db = self._conn()
open_rows = db.execute("SELECT id, started_at FROM cycles WHERE ended_at IS NULL").fetchall()
closed = 0
for row in open_rows:
last_sample = db.execute(
"SELECT sampled_at FROM samples WHERE sampled_at >= ? ORDER BY sampled_at DESC LIMIT 1",
(row["started_at"],),
).fetchone()
ended_at = from_db_time(last_sample["sampled_at"]) if last_sample else from_db_time(row["started_at"])
self.finish_cycle(int(row["id"]), ended_at, incomplete_end=True)
closed += 1
return closed
def recent_cycles(self, limit: int = 100) -> list[dict[str, Any]]:
rows = self._conn().execute(
"SELECT * FROM cycles WHERE ended_at IS NOT NULL ORDER BY started_at DESC LIMIT ?",
(max(1, min(limit, 1000)),),
).fetchall()
return [dict(row) for row in rows]
def cycles_between(self, start: datetime, end: datetime) -> list[dict[str, Any]]:
rows = self._conn().execute(
"""
SELECT * FROM cycles
WHERE started_at < ? AND COALESCE(ended_at, ?) >= ?
ORDER BY started_at DESC
""",
(to_db_time(end), to_db_time(end), to_db_time(start)),
).fetchall()
return [dict(row) for row in rows]
def summary_between(self, start: datetime, end: datetime) -> dict[str, Any]:
cycles = self.cycles_between(start, end)
durations: list[float] = []
energy_wh = 0.0
for cycle in cycles:
cycle_start = max(start, from_db_time(cycle["started_at"]))
cycle_end_raw = from_db_time(cycle["ended_at"]) if cycle["ended_at"] else min(end, utc_now())
cycle_end = min(end, cycle_end_raw)
durations.append(max(0.0, (cycle_end - cycle_start).total_seconds()))
energy_wh += float(cycle["energy_wh"] or 0.0)
return {
"cycle_count": len(cycles),
"runtime_seconds": sum(durations),
"average_duration_seconds": sum(durations) / len(durations) if durations else 0.0,
"longest_duration_seconds": max(durations, default=0.0),
"energy_wh": energy_wh,
}
def sample_history(
self,
since: datetime,
until: datetime | None = None,
max_points: int = 800,
preserve_ranges: list[tuple[datetime, datetime]] | None = None,
) -> list[dict[str, Any]]:
until = until or utc_now()
rows = self._conn().execute(
"SELECT sampled_at, watts FROM samples WHERE sampled_at >= ? AND sampled_at <= ? ORDER BY sampled_at",
(to_db_time(since), to_db_time(until)),
).fetchall()
if len(rows) <= max_points:
selected = list(rows)
else:
# First/minimum/maximum/last preserves short power spikes that a
# simple stride can skip entirely. All readings inside known pump
# cycles are also retained for accurate hover details.
bucket_count = max(1, max_points // 4)
bucket_size = max(1, math.ceil(len(rows) / bucket_count))
selected_by_time: dict[str, sqlite3.Row] = {}
for offset in range(0, len(rows), bucket_size):
bucket = rows[offset : offset + bucket_size]
for row in (
bucket[0],
min(bucket, key=lambda item: float(item["watts"])),
max(bucket, key=lambda item: float(item["watts"])),
bucket[-1],
):
selected_by_time[row["sampled_at"]] = row
ranges = preserve_ranges or []
if ranges:
normalized = [(start.astimezone(UTC), end.astimezone(UTC)) for start, end in ranges]
for row in rows:
sampled_at = from_db_time(row["sampled_at"])
if any(start <= sampled_at <= end for start, end in normalized):
selected_by_time[row["sampled_at"]] = row
selected = sorted(selected_by_time.values(), key=lambda row: row["sampled_at"])
return [{"sampled_at": row["sampled_at"], "watts": row["watts"]} for row in selected]
def cleanup_samples(self, retention_days: int) -> int:
cutoff = to_db_time(utc_now() - timedelta(days=retention_days))
db = self._conn()
cursor = db.execute("DELETE FROM samples WHERE sampled_at < ?", (cutoff,))
db.commit()
return int(cursor.rowcount)
+456
View File
@@ -0,0 +1,456 @@
DASHBOARD_HTML = r"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="theme-color" content="#0d1717">
<title>Waterworks · Pump monitor</title>
<style>
:root {
--ink: #e7eee8;
--muted: #8fa19a;
--paper: #0d1717;
--panel: #142220;
--line: #29413d;
--active: #8ee6bb;
--active-deep: #2ea778;
--warning: #f0bd69;
--danger: #ee806f;
--idle: #70827c;
}
* { box-sizing: border-box; }
html { background: var(--paper); color: var(--ink); }
body {
margin: 0;
min-height: 100vh;
font-family: "Avenir Next", Avenir, "Segoe UI", sans-serif;
background:
linear-gradient(rgba(142, 230, 187, .035) 1px, transparent 1px),
linear-gradient(90deg, rgba(142, 230, 187, .035) 1px, transparent 1px),
radial-gradient(circle at 85% 0%, rgba(46, 167, 120, .18), transparent 34rem),
var(--paper);
background-size: 32px 32px, 32px 32px, auto, auto;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
opacity: .16;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.22'/%3E%3C/svg%3E");
mix-blend-mode: soft-light;
}
.shell { width: min(1180px, calc(100% - 32px)); margin: 0 auto; padding: 42px 0 64px; }
header { display: grid; grid-template-columns: 1fr auto; gap: 24px; align-items: end; margin-bottom: 30px; }
.eyebrow, .metric-label, th, .section-kicker {
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
text-transform: uppercase;
letter-spacing: .13em;
font-size: 11px;
color: var(--muted);
}
h1 { margin: 4px 0 0; font-family: Georgia, "Times New Roman", serif; font-size: clamp(38px, 7vw, 74px); font-weight: 400; letter-spacing: -.045em; line-height: .98; }
.connection { display: flex; gap: 9px; align-items: center; padding-bottom: 8px; color: var(--muted); font-size: 13px; }
.dot { width: 9px; height: 9px; border-radius: 99px; background: var(--idle); box-shadow: 0 0 0 4px rgba(112,130,124,.12); }
.dot.live { background: var(--active); box-shadow: 0 0 0 4px rgba(142,230,187,.12), 0 0 18px rgba(142,230,187,.45); }
.dot.offline { background: var(--danger); box-shadow: 0 0 0 4px rgba(238,128,111,.12); }
.hero { display: grid; grid-template-columns: minmax(0, 1.55fr) minmax(260px, .75fr); gap: 16px; }
.panel { position: relative; background: rgba(20, 34, 32, .92); border: 1px solid var(--line); border-radius: 3px; overflow: hidden; }
.last-run { min-height: 310px; padding: 30px; display: flex; flex-direction: column; justify-content: space-between; }
.last-run::after { content: ""; position: absolute; inset: auto -10% -58% 36%; height: 290px; border: 1px solid rgba(142,230,187,.14); border-radius: 50%; box-shadow: 0 0 60px rgba(46,167,120,.06) inset; }
.last-run-emphasis { position: relative; z-index: 1; display: flex; align-items: baseline; gap: 14px; }
.last-run-value { font-family: "SFMono-Regular", Consolas, monospace; font-size: clamp(58px, 9vw, 106px); letter-spacing: -.075em; line-height: .88; color: var(--ink); }
.last-run-unit { color: var(--muted); font-size: 14px; letter-spacing: .04em; }
.run-facts { position: relative; z-index: 1; display: grid; grid-template-columns: 1.25fr 1.25fr .75fr .75fr; border-top: 1px solid var(--line); }
.run-fact { min-width: 0; padding: 17px 17px 0 0; }
.run-fact + .run-fact { padding-left: 17px; border-left: 1px solid var(--line); }
.run-fact span { display: block; margin-bottom: 8px; font: 10px/1 "SFMono-Regular", Consolas, monospace; letter-spacing: .12em; text-transform: uppercase; color: var(--muted); }
.run-fact strong { display: block; color: var(--ink); font-size: 13px; font-weight: 500; line-height: 1.35; font-variant-numeric: tabular-nums; }
.metrics { display: grid; grid-template-columns: 1fr 1fr; }
.metric { min-height: 155px; padding: 24px; border-bottom: 1px solid var(--line); border-right: 1px solid var(--line); }
.metric:nth-child(2n) { border-right: 0; }
.metric:nth-last-child(-n+2) { border-bottom: 0; }
.metric-value { margin-top: 14px; font-family: Georgia, serif; font-size: 37px; letter-spacing: -.04em; }
.metric-value small { font-family: "Avenir Next", sans-serif; font-size: 13px; color: var(--muted); margin-left: 5px; }
.chart-panel { margin-top: 16px; padding: 25px 25px 18px; }
.section-head { display: flex; justify-content: space-between; align-items: end; gap: 16px; margin-bottom: 22px; }
h2 { margin: 4px 0 0; font-family: Georgia, serif; font-size: 27px; font-weight: 400; }
.threshold { color: var(--muted); font-size: 12px; }
.chart-wrap { height: 250px; position: relative; }
.chart-plot { position: absolute; inset: 6px 0 38px 62px; cursor: crosshair; touch-action: pan-y; }
svg { width: 100%; height: 100%; overflow: visible; }
.chart-grid { stroke: rgba(143,161,154,.18); stroke-width: 1; vector-effect: non-scaling-stroke; }
.chart-area { fill: url(#chartFill); }
.chart-line { fill: none; stroke: var(--active); stroke-width: 2; vector-effect: non-scaling-stroke; }
.run-band { fill: rgba(142,230,187,.17); stroke: rgba(142,230,187,.58); stroke-width: 1; vector-effect: non-scaling-stroke; }
.hover-line { stroke: rgba(231,238,232,.45); stroke-width: 1; stroke-dasharray: 4 4; vector-effect: non-scaling-stroke; pointer-events: none; }
.hover-dot { fill: var(--paper); stroke: var(--active); stroke-width: 3; vector-effect: non-scaling-stroke; pointer-events: none; }
.chart-empty { position: absolute; inset: 6px 0 38px 62px; display: grid; place-items: center; color: var(--muted); font-family: Georgia, serif; font-size: 18px; pointer-events: none; }
.x-axis { position: absolute; left: 62px; right: 0; bottom: 0; height: 31px; color: var(--muted); font: 10px/1.2 "SFMono-Regular", Consolas, monospace; }
.x-tick { position: absolute; top: 8px; transform: translateX(-50%); white-space: nowrap; text-align: center; }
.x-tick::before { content: ""; position: absolute; left: 50%; top: -8px; width: 1px; height: 5px; background: var(--line); }
.x-tick.first { transform: none; text-align: left; }
.x-tick.first::before { left: 0; }
.x-tick.last { transform: translateX(-100%); text-align: right; }
.x-tick.last::before { left: 100%; }
.y-axis { position: absolute; left: 0; top: 6px; bottom: 38px; width: 51px; color: var(--muted); font: 10px/1 "SFMono-Regular", Consolas, monospace; }
.y-tick { position: absolute; right: 0; transform: translateY(-50%); font-variant-numeric: tabular-nums; }
.chart-tooltip { display: none; position: absolute; z-index: 4; min-width: 190px; padding: 12px 13px; border: 1px solid rgba(142,230,187,.55); background: rgba(7,16,15,.96); box-shadow: 0 15px 35px rgba(0,0,0,.35); pointer-events: none; }
.chart-tooltip.show { display: block; }
.tooltip-time { margin-bottom: 9px; color: var(--active); font: 10px/1.2 "SFMono-Regular", Consolas, monospace; letter-spacing: .04em; }
.tooltip-row { display: flex; justify-content: space-between; gap: 22px; padding: 3px 0; color: var(--muted); font-size: 12px; }
.tooltip-row strong { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
.tooltip-run { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--line); }
.tooltip-run strong { color: var(--active); }
.table-panel { margin-top: 16px; }
.table-head { padding: 25px; border-bottom: 1px solid var(--line); }
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 660px; }
th, td { padding: 16px 25px; text-align: left; border-bottom: 1px solid rgba(41,65,61,.72); }
th { font-weight: 500; }
td { font-size: 14px; font-variant-numeric: tabular-nums; }
tr:last-child td { border-bottom: 0; }
.duration { font-family: Georgia, serif; font-size: 18px; }
.flag { display: inline-block; padding: 4px 7px; margin-left: 8px; color: var(--warning); border: 1px solid rgba(240,189,105,.35); font-size: 10px; text-transform: uppercase; letter-spacing: .08em; }
.empty-row { color: var(--muted); text-align: center; padding: 36px; }
.notice { display: none; margin-bottom: 16px; padding: 14px 16px; border: 1px solid rgba(240,189,105,.45); color: var(--warning); background: rgba(240,189,105,.06); font-size: 13px; }
.notice.show { display: block; }
footer { margin-top: 18px; display: flex; justify-content: space-between; color: var(--muted); font-size: 11px; font-family: "SFMono-Regular", Consolas, monospace; }
a { color: var(--active); text-decoration: none; }
@media (max-width: 760px) {
.shell { width: min(100% - 22px, 1180px); padding-top: 26px; }
header, .hero { grid-template-columns: 1fr; }
.connection { padding: 0; }
.last-run { min-height: 280px; padding: 23px; }
.run-facts { grid-template-columns: 1fr 1fr; }
.run-fact { padding: 14px 12px 0 0; }
.run-fact + .run-fact { padding-left: 12px; }
.run-fact:nth-child(3) { padding-left: 0; border-left: 0; }
.run-fact:nth-child(n+3) { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--line); }
.metric { min-height: 132px; padding: 19px; }
.metric-value { font-size: 31px; }
.chart-panel, .table-head { padding: 20px; }
.chart-plot { left: 51px; }
.chart-empty { left: 51px; }
.x-axis { left: 51px; }
.y-axis { width: 41px; }
th, td { padding: 14px 20px; }
footer { flex-direction: column; gap: 7px; }
}
@media (prefers-reduced-motion: no-preference) {
.panel { animation: settle .5s ease both; }
.chart-panel { animation-delay: .08s; }
.table-panel { animation-delay: .14s; }
@keyframes settle { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
}
</style>
</head>
<body>
<main class="shell">
<header>
<div>
<div class="eyebrow">Domestic waterworks / live telemetry</div>
<h1>Pump monitor</h1>
</div>
<div class="connection"><span id="dot" class="dot"></span><span id="connectionText">Connecting…</span></div>
</header>
<div id="notice" class="notice"></div>
<section class="hero">
<article class="panel last-run">
<div class="metric-label">Last completed run</div>
<div class="last-run-emphasis">
<div id="lastRunDuration" class="last-run-value">—</div>
<div class="last-run-unit">duration</div>
</div>
<div class="run-facts">
<div class="run-fact"><span>Started</span><strong id="lastRunStarted">—</strong></div>
<div class="run-fact"><span>Stopped</span><strong id="lastRunStopped">—</strong></div>
<div class="run-fact"><span>Average</span><strong id="lastRunAverage">—</strong></div>
<div class="run-fact"><span>Peak</span><strong id="lastRunPeak">—</strong></div>
</div>
</article>
<article class="panel metrics">
<div class="metric"><div class="metric-label">Cycles today</div><div id="cycleCount" class="metric-value">—</div></div>
<div class="metric"><div class="metric-label">Runtime today</div><div id="runtime" class="metric-value">—</div></div>
<div class="metric"><div class="metric-label">Average cycle</div><div id="average" class="metric-value">—</div></div>
<div class="metric"><div class="metric-label">Longest cycle</div><div id="longest" class="metric-value">—</div></div>
</article>
</section>
<section class="panel chart-panel">
<div class="section-head">
<div><div class="section-kicker">Last 24 hours</div><h2>Power trace</h2></div>
<div id="threshold" class="threshold"></div>
</div>
<div class="chart-wrap">
<div id="yAxis" class="y-axis" aria-hidden="true"></div>
<div id="chartPlot" class="chart-plot">
<svg id="chart" viewBox="0 0 1000 190" preserveAspectRatio="none" aria-label="Power draw timeline for the last 24 hours">
<defs><linearGradient id="chartFill" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#8ee6bb" stop-opacity=".28"/><stop offset="1" stop-color="#8ee6bb" stop-opacity="0"/></linearGradient></defs>
<g id="chartGrid"></g><g id="runBands"></g>
<path id="chartArea" class="chart-area" d=""/><path id="chartLine" class="chart-line" d=""/>
<line id="hoverX" class="hover-line" x1="0" y1="0" x2="0" y2="190" visibility="hidden"/>
<line id="hoverY" class="hover-line" x1="0" y1="0" x2="1000" y2="0" visibility="hidden"/>
<circle id="hoverDot" class="hover-dot" cx="0" cy="0" r="5" visibility="hidden"/>
</svg>
<div id="chartTooltip" class="chart-tooltip"></div>
</div>
<div id="xAxis" class="x-axis" aria-hidden="true"></div>
<div id="chartEmpty" class="chart-empty">No readings yet</div>
</div>
</section>
<section class="panel table-panel">
<div class="section-head table-head">
<div><div class="section-kicker">Recorded events</div><h2>Recent pump cycles</h2></div>
<a href="/api/cycles.csv?days=30">Download CSV</a>
</div>
<div class="table-scroll">
<table>
<thead><tr><th>Started</th><th>Stopped</th><th>Duration</th><th>Average</th><th>Peak</th></tr></thead>
<tbody id="cycles"><tr><td colspan="5" class="empty-row">No completed cycles yet</td></tr></tbody>
</table>
</div>
</section>
<footer><span id="lastUpdate">Awaiting first reading</span><span>Times shown in your browser timezone</span></footer>
</main>
<script>
const $ = (id) => document.getElementById(id);
const fmtTime = (iso) => iso ? new Intl.DateTimeFormat(undefined, {dateStyle:'medium', timeStyle:'medium'}).format(new Date(iso)) : '—';
const fmtDuration = (seconds) => {
seconds = Math.round(Number(seconds || 0));
if (seconds < 60) return `${seconds} sec`;
const mins = Math.floor(seconds / 60), secs = seconds % 60;
if (mins < 60) return `${mins}m ${secs}s`;
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
};
const fmtW = (value) => value == null ? '—' : `${Math.round(value)} W`;
const fmtRunTime = (iso) => iso ? new Intl.DateTimeFormat(undefined, {
month:'short', day:'numeric', hour:'2-digit', minute:'2-digit', second:'2-digit'
}).format(new Date(iso)) : '—';
function setNotice(message) {
$('notice').textContent = message || '';
$('notice').classList.toggle('show', Boolean(message));
}
function renderStatus(data) {
const status = data.monitor;
const summary = data.today;
const live = status.state === 'running' || status.state === 'idle';
$('dot').className = `dot ${live ? 'live' : 'offline'}`;
$('connectionText').textContent = live ? `Socket online · ${fmtTime(status.last_success)}` : 'Socket data unavailable';
$('cycleCount').textContent = summary.cycle_count;
$('runtime').textContent = fmtDuration(summary.runtime_seconds);
$('average').textContent = fmtDuration(summary.average_duration_seconds);
$('longest').textContent = fmtDuration(summary.longest_duration_seconds);
$('threshold').textContent = `Start ≥ ${data.settings.start_watts} W · stop ≤ ${data.settings.stop_watts} W`;
$('lastUpdate').textContent = status.last_success ? `Last reading ${fmtTime(status.last_success)}` : 'Awaiting first reading';
setNotice(status.error || (!data.security.dashboard_auth_enabled ? 'Dashboard authentication is disabled. Set DASHBOARD_PASSWORD before exposing this page publicly.' : ''));
}
function renderLastRun(row) {
if (!row) {
$('lastRunDuration').textContent = '—';
for (const id of ['lastRunStarted', 'lastRunStopped', 'lastRunAverage', 'lastRunPeak']) $(id).textContent = '—';
return;
}
$('lastRunDuration').textContent = fmtDuration(row.duration_seconds);
$('lastRunStarted').textContent = fmtRunTime(row.started_at);
$('lastRunStopped').textContent = fmtRunTime(row.ended_at);
$('lastRunAverage').textContent = fmtW(row.average_watts);
$('lastRunPeak').textContent = fmtW(row.maximum_watts);
}
function renderCycles(rows) {
if (!rows.length) {
$('cycles').innerHTML = '<tr><td colspan="5" class="empty-row">No completed cycles yet</td></tr>';
return;
}
$('cycles').innerHTML = rows.map(row => {
const incomplete = row.incomplete_start || row.incomplete_end;
return `<tr><td>${fmtTime(row.started_at)}${incomplete ? '<span class="flag">partial</span>' : ''}</td><td>${fmtTime(row.ended_at)}</td><td class="duration">${fmtDuration(row.duration_seconds)}</td><td>${fmtW(row.average_watts)}</td><td>${fmtW(row.maximum_watts)}</td></tr>`;
}).join('');
}
const CHART_WIDTH = 1000;
const CHART_HEIGHT = 190;
const CHART_TOP = 8;
const CHART_BASE = 184;
let chartModel = null;
function niceCeiling(value) {
if (!Number.isFinite(value) || value <= 0) return 100;
const magnitude = 10 ** Math.floor(Math.log10(value));
const normalized = value / magnitude;
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 2.5 ? 2.5 : normalized <= 5 ? 5 : 10;
return nice * magnitude;
}
function renderAxes(minT, maxT, maxW) {
const yTicks = 5;
$('chartGrid').innerHTML = Array.from({length: yTicks}, (_, index) => {
const y = CHART_TOP + ((CHART_BASE - CHART_TOP) * index / (yTicks - 1));
return `<line class="chart-grid" x1="0" y1="${y}" x2="${CHART_WIDTH}" y2="${y}"/>`;
}).join('');
$('yAxis').innerHTML = Array.from({length: yTicks}, (_, index) => {
const top = index * 100 / (yTicks - 1);
const value = maxW * (1 - index / (yTicks - 1));
return `<span class="y-tick" style="top:${top}%">${Math.round(value).toLocaleString()} W</span>`;
}).join('');
const tickCount = $('chartPlot').clientWidth < 580 ? 3 : 5;
const dayFormat = new Intl.DateTimeFormat(undefined, {day:'numeric', month:'short'});
const timeFormat = new Intl.DateTimeFormat(undefined, {hour:'2-digit', minute:'2-digit'});
$('xAxis').innerHTML = Array.from({length: tickCount}, (_, index) => {
const ratio = index / (tickCount - 1);
const date = new Date(minT + (maxT - minT) * ratio);
const edgeClass = index === 0 ? ' first' : index === tickCount - 1 ? ' last' : '';
const label = index === 0 || index === tickCount - 1 ? `${dayFormat.format(date)} · ${timeFormat.format(date)}` : timeFormat.format(date);
return `<span class="x-tick${edgeClass}" style="left:${ratio * 100}%">${label}</span>`;
}).join('');
}
function renderChart(history) {
const points = history.samples || [];
const cycles = history.cycles || [];
const minT = new Date(history.window_start).getTime();
const maxT = new Date(history.window_end).getTime();
const empty = points.length < 2;
$('chartEmpty').style.display = empty ? 'grid' : 'none';
const times = points.map(p => new Date(p.sampled_at).getTime());
const watts = points.map(p => Number(p.watts));
const cyclePeaks = cycles.map(cycle => Number(cycle.maximum_watts || 0));
const maxW = niceCeiling(Math.max(100, ...watts, ...cyclePeaks) * 1.04);
renderAxes(minT, maxT, maxW);
chartModel = {points, cycles, minT, maxT, maxW};
$('runBands').innerHTML = cycles.map(cycle => {
const start = Math.max(minT, new Date(cycle.started_at).getTime());
const end = Math.min(maxT, cycle.ended_at ? new Date(cycle.ended_at).getTime() : maxT);
let x = ((start - minT) / (maxT - minT)) * CHART_WIDTH;
let width = Math.max(3, ((end - start) / (maxT - minT)) * CHART_WIDTH);
x = Math.max(0, Math.min(CHART_WIDTH - width, x - Math.max(0, (width - ((end - start) / (maxT - minT)) * CHART_WIDTH) / 2)));
return `<rect class="run-band" x="${x.toFixed(2)}" y="${CHART_TOP}" width="${width.toFixed(2)}" height="${CHART_BASE - CHART_TOP}"/>`;
}).join('');
if (empty) {
$('chartLine').setAttribute('d', '');
$('chartArea').setAttribute('d', '');
return;
}
const coords = points.map((p, i) => {
const x = ((times[i] - minT) / Math.max(1, maxT - minT)) * CHART_WIDTH;
const y = CHART_BASE - (watts[i] / maxW) * (CHART_BASE - CHART_TOP);
return [x, y];
});
const line = coords.map((p, i) => `${i ? 'L' : 'M'}${p[0].toFixed(2)},${p[1].toFixed(2)}`).join(' ');
$('chartLine').setAttribute('d', line);
$('chartArea').setAttribute('d', `${line} L${coords.at(-1)[0].toFixed(2)},${CHART_BASE} L${coords[0][0].toFixed(2)},${CHART_BASE} Z`);
}
function nearestSample(points, target) {
let low = 0, high = points.length - 1;
while (low < high) {
const middle = Math.floor((low + high) / 2);
if (new Date(points[middle].sampled_at).getTime() < target) low = middle + 1;
else high = middle;
}
const current = points[low];
const previous = points[Math.max(0, low - 1)];
return Math.abs(new Date(previous.sampled_at) - target) <= Math.abs(new Date(current.sampled_at) - target) ? previous : current;
}
function nearestCycle(cycles, target, tolerance) {
const containing = cycles.find(cycle => {
const start = new Date(cycle.started_at).getTime();
const end = cycle.ended_at ? new Date(cycle.ended_at).getTime() : Date.now();
return target >= start && target <= end;
});
if (containing) return containing;
let best = null, bestDistance = Infinity;
for (const cycle of cycles) {
const start = new Date(cycle.started_at).getTime();
const end = cycle.ended_at ? new Date(cycle.ended_at).getTime() : Date.now();
const distance = Math.abs((start + end) / 2 - target);
if (distance < bestDistance) { best = cycle; bestDistance = distance; }
}
return bestDistance <= tolerance ? best : null;
}
function showChartTooltip(event) {
if (!chartModel || !chartModel.points.length) return;
const plot = $('chartPlot');
const rect = plot.getBoundingClientRect();
const pointerX = Math.max(0, Math.min(rect.width, event.clientX - rect.left));
let target = chartModel.minT + (pointerX / rect.width) * (chartModel.maxT - chartModel.minT);
const tolerance = (8 / rect.width) * (chartModel.maxT - chartModel.minT);
const cycle = nearestCycle(chartModel.cycles, target, tolerance);
if (cycle && !(target >= new Date(cycle.started_at).getTime() && target <= new Date(cycle.ended_at || Date.now()).getTime())) {
const cycleEnd = new Date(cycle.ended_at || Date.now()).getTime();
target = (new Date(cycle.started_at).getTime() + cycleEnd) / 2;
}
const sample = nearestSample(chartModel.points, target);
const sampleTime = new Date(sample.sampled_at).getTime();
const x = ((sampleTime - chartModel.minT) / (chartModel.maxT - chartModel.minT)) * CHART_WIDTH;
const y = CHART_BASE - (Number(sample.watts) / chartModel.maxW) * (CHART_BASE - CHART_TOP);
for (const id of ['hoverX', 'hoverY', 'hoverDot']) $(id).setAttribute('visibility', 'visible');
$('hoverX').setAttribute('x1', x); $('hoverX').setAttribute('x2', x);
$('hoverY').setAttribute('y1', y); $('hoverY').setAttribute('y2', y);
$('hoverDot').setAttribute('cx', x); $('hoverDot').setAttribute('cy', y);
const duration = cycle ? Number(cycle.duration_seconds || ((new Date(cycle.ended_at || Date.now()) - new Date(cycle.started_at)) / 1000)) : null;
const partial = cycle && (cycle.incomplete_start || cycle.incomplete_end) ? ' · partial' : '';
$('chartTooltip').innerHTML = `<div class="tooltip-time">${fmtTime(sample.sampled_at)}</div><div class="tooltip-row"><span>Power draw</span><strong>${fmtW(sample.watts)}</strong></div><div class="tooltip-row tooltip-run"><span>${cycle ? 'Run duration' : 'Pump state'}</span><strong>${cycle ? `${fmtDuration(duration)}${partial}` : 'Idle'}</strong></div>`;
$('chartTooltip').classList.add('show');
const tooltipWidth = $('chartTooltip').offsetWidth;
const tooltipHeight = $('chartTooltip').offsetHeight;
const localX = (x / CHART_WIDTH) * rect.width;
const localY = (y / CHART_HEIGHT) * rect.height;
$('chartTooltip').style.left = `${Math.max(6, Math.min(rect.width - tooltipWidth - 6, localX + 12))}px`;
$('chartTooltip').style.top = `${Math.max(6, Math.min(rect.height - tooltipHeight - 6, localY - tooltipHeight / 2))}px`;
}
function hideChartTooltip() {
$('chartTooltip').classList.remove('show');
for (const id of ['hoverX', 'hoverY', 'hoverDot']) $(id).setAttribute('visibility', 'hidden');
}
$('chartPlot').addEventListener('pointermove', showChartTooltip);
$('chartPlot').addEventListener('pointerdown', showChartTooltip);
$('chartPlot').addEventListener('pointerleave', hideChartTooltip);
async function refresh() {
try {
const [statusResponse, cyclesResponse, historyResponse] = await Promise.all([
fetch('/api/status'), fetch('/api/cycles?limit=50'), fetch('/api/history?hours=24')
]);
if (!statusResponse.ok) throw new Error(`Dashboard request failed (${statusResponse.status})`);
const statusData = await statusResponse.json();
const cycleRows = (await cyclesResponse.json()).cycles;
renderStatus(statusData);
renderLastRun(cycleRows[0]);
renderCycles(cycleRows);
renderChart(await historyResponse.json());
} catch (error) {
setNotice(error.message);
$('dot').className = 'dot offline';
$('connectionText').textContent = 'Dashboard connection lost';
}
}
refresh();
setInterval(refresh, 5000);
window.addEventListener('resize', () => chartModel && renderChart({
samples: chartModel.points,
cycles: chartModel.cycles,
window_start: new Date(chartModel.minT).toISOString(),
window_end: new Date(chartModel.maxT).toISOString()
}));
</script>
</body>
</html>"""