Initial pump monitor with Coolify deployment
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Tapo P110 pump-cycle monitor."""
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>"""
|
||||
Reference in New Issue
Block a user