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
+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)