Initial pump monitor with Coolify deployment
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.git
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.sqlite3
|
||||
tests
|
||||
@@ -0,0 +1,16 @@
|
||||
# Add these values as Coolify environment variables/secrets.
|
||||
TAPO_HOST=192.168.1.50
|
||||
TAPO_USERNAME=[email protected]
|
||||
TAPO_PASSWORD=replace-with-a-coolify-secret
|
||||
|
||||
TZ=Europe/Prague
|
||||
POLL_INTERVAL_SECONDS=2
|
||||
START_WATTS=100
|
||||
STOP_WATTS=30
|
||||
CONFIRM_SAMPLES=2
|
||||
MAX_GAP_SECONDS=20
|
||||
SAMPLE_RETENTION_DAYS=30
|
||||
|
||||
DASHBOARD_PORT=8000
|
||||
DASHBOARD_USERNAME=water-monitor
|
||||
DASHBOARD_PASSWORD=replace-with-a-long-random-password
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.py[cod]
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
*.sqlite3-shm
|
||||
*.sqlite3-wal
|
||||
data/
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
FROM python:3.12.11-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DATABASE_PATH=/data/pump-monitor.sqlite3
|
||||
|
||||
WORKDIR /srv/app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --requirement requirements.txt \
|
||||
&& addgroup --system --gid 10001 monitor \
|
||||
&& adduser --system --uid 10001 --ingroup monitor monitor \
|
||||
&& mkdir -p /data \
|
||||
&& chown monitor:monitor /data
|
||||
|
||||
COPY --chown=monitor:monitor app ./app
|
||||
|
||||
USER monitor
|
||||
EXPOSE 8000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)" || exit 1
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1", "--proxy-headers", "--forwarded-allow-ips", "*"]
|
||||
@@ -0,0 +1,95 @@
|
||||
# Tapo P110 pump-cycle monitor
|
||||
|
||||
A small, private monitor for a domestic waterworks pump connected through a Tapo P110. It reads power locally, records the first and last threshold crossing for every run, and presents the results in a phone-friendly dashboard.
|
||||
|
||||
## What it records
|
||||
|
||||
- Start and stop time for every pump cycle
|
||||
- Duration, average power, peak power, and estimated energy per cycle
|
||||
- A rolling 24-hour power trace
|
||||
- Daily cycle count, total runtime, average duration, and longest duration
|
||||
- Partial-cycle markers after restarts or communication gaps
|
||||
- CSV export of completed cycles
|
||||
|
||||
With the default two-second polling interval, event times have approximately two-second resolution. Two consecutive readings confirm a transition, but the saved event time is the first threshold crossing.
|
||||
|
||||
## Run locally with Docker
|
||||
|
||||
1. Open the local `.env` file in this directory.
|
||||
2. Enter `TAPO_USERNAME` and `TAPO_PASSWORD` using the account from the Tapo app.
|
||||
3. Run `docker compose up --detach --build` from this directory.
|
||||
4. Open [http://localhost:8000](http://localhost:8000).
|
||||
|
||||
The dashboard is bound to `127.0.0.1`, so it is only available on this computer. Recorded data is stored in a named Docker volume and survives container rebuilds.
|
||||
|
||||
Required variables:
|
||||
|
||||
| Variable | Value |
|
||||
| --- | --- |
|
||||
| `TAPO_HOST` | The plug's reserved LAN address, for example `192.168.1.50` |
|
||||
| `TAPO_USERNAME` | The email address used for the Tapo account |
|
||||
| `TAPO_PASSWORD` | The Tapo account password; keep it only in the ignored local `.env` file |
|
||||
| `DASHBOARD_PASSWORD` | Optional locally; use a long password if the dashboard is ever exposed beyond localhost |
|
||||
|
||||
Useful defaults already included in `compose.yaml`:
|
||||
|
||||
| Variable | Default | Meaning |
|
||||
| --- | ---: | --- |
|
||||
| `TZ` | `Europe/Prague` | Boundary used for daily totals |
|
||||
| `POLL_INTERVAL_SECONDS` | `2` | Time between readings |
|
||||
| `START_WATTS` | `100` | Two readings at or above this start a cycle |
|
||||
| `STOP_WATTS` | `30` | Two readings at or below this stop a cycle |
|
||||
| `CONFIRM_SAMPLES` | `2` | Consecutive readings required |
|
||||
| `MAX_GAP_SECONDS` | `20` | Longer data gaps split and mark partial cycles |
|
||||
| `SAMPLE_RETENTION_DAYS` | `30` | Raw chart history retention; cycle records remain |
|
||||
| `DASHBOARD_PORT` | `8000` | Local dashboard port |
|
||||
| `DASHBOARD_USERNAME` | `water-monitor` | Dashboard login name when a password is configured |
|
||||
|
||||
Do not paste real credentials into `.env.example`, `compose.yaml`, Git, or application logs. The `.env` file is excluded by `.gitignore` and `.dockerignore`.
|
||||
|
||||
## Deploy with Coolify and Gitea
|
||||
|
||||
Use `compose.coolify.yaml` for the Coolify deployment. It exposes container port 8000 to Coolify's proxy without publishing a host port and stores SQLite data in the persistent `tapo-pump-monitor-data` Docker volume.
|
||||
|
||||
1. Create an application in Coolify from this Gitea repository.
|
||||
2. Select Docker Compose and set the compose file to `/compose.coolify.yaml`.
|
||||
3. Add `TAPO_HOST`, `TAPO_USERNAME`, `TAPO_PASSWORD`, and `DASHBOARD_PASSWORD` as Coolify environment variables. Do not save their real values in Git.
|
||||
4. Add a domain in Coolify and route it to the `pump-monitor` service on port 8000.
|
||||
5. Deploy and confirm that the service becomes healthy and can reach the plug on the home LAN.
|
||||
|
||||
Keep only one instance running. Stop the local Docker deployment at cutover so the same pump cycle is not recorded twice. The local SQLite history can be copied into the Coolify volume before the first server start if it needs to be preserved.
|
||||
|
||||
## Before starting
|
||||
|
||||
- Reserve the plug's LAN address in the router so DHCP does not change it.
|
||||
- In the Tapo app, enable **Me > Third-Party Services > Third-Party Compatibility**. Newer P110 firmware otherwise advertises the unsupported TPAP-only local protocol.
|
||||
- Keep exactly one instance of this service. Multiple instances would record duplicate cycles.
|
||||
|
||||
Docker's normal bridge network can initiate connections to devices on the home LAN, so host networking is not required.
|
||||
|
||||
## Calibrate the thresholds
|
||||
|
||||
The defaults are intentionally conservative. After deployment:
|
||||
|
||||
1. Watch the live wattage while the pump is idle.
|
||||
2. Open a tap and note the stable running wattage.
|
||||
3. Set `START_WATTS` comfortably below the running value but above all idle noise.
|
||||
4. Set `STOP_WATTS` above idle consumption and below `START_WATTS`.
|
||||
|
||||
For example, if idle is 1 W and the pump is around 750 W, the defaults of 100 W to start and 30 W to stop are suitable.
|
||||
|
||||
## Useful commands
|
||||
|
||||
```bash
|
||||
docker compose up --detach --build # build and start
|
||||
docker compose ps # show health and port
|
||||
docker compose logs --follow # watch connection and cycle events
|
||||
docker compose restart # restart without deleting data
|
||||
docker compose down # stop; recorded data remains
|
||||
```
|
||||
|
||||
Do not add `--volumes` to `docker compose down` unless you intentionally want to erase all recorded history.
|
||||
|
||||
## Interpreting likely problems
|
||||
|
||||
Once a normal baseline is established, useful warning signals include unusually short repeated cycles, a run much longer than normal, a sharp increase in cycles per hour, or a meaningful change in average running wattage. The dashboard records the evidence first; alert rules can be added after several days of normal data show appropriate limits.
|
||||
@@ -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>"""
|
||||
@@ -0,0 +1,28 @@
|
||||
services:
|
||||
pump-monitor:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "8000"
|
||||
environment:
|
||||
- TAPO_HOST=${TAPO_HOST}
|
||||
- TAPO_USERNAME=${TAPO_USERNAME}
|
||||
- TAPO_PASSWORD=${TAPO_PASSWORD}
|
||||
- TZ=${TZ:-Europe/Prague}
|
||||
- POLL_INTERVAL_SECONDS=${POLL_INTERVAL_SECONDS:-2}
|
||||
- START_WATTS=${START_WATTS:-100}
|
||||
- STOP_WATTS=${STOP_WATTS:-30}
|
||||
- CONFIRM_SAMPLES=${CONFIRM_SAMPLES:-2}
|
||||
- MAX_GAP_SECONDS=${MAX_GAP_SECONDS:-20}
|
||||
- SAMPLE_RETENTION_DAYS=${SAMPLE_RETENTION_DAYS:-30}
|
||||
- DATABASE_PATH=/data/pump-monitor.sqlite3
|
||||
- DASHBOARD_USERNAME=${DASHBOARD_USERNAME:-water-monitor}
|
||||
- DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD}
|
||||
volumes:
|
||||
- pump-monitor-data:/data
|
||||
|
||||
volumes:
|
||||
pump-monitor-data:
|
||||
name: tapo-pump-monitor-data
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
pump-monitor:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${DASHBOARD_PORT:-8000}:8000"
|
||||
environment:
|
||||
- TAPO_HOST=${TAPO_HOST}
|
||||
- TAPO_USERNAME=${TAPO_USERNAME}
|
||||
- TAPO_PASSWORD=${TAPO_PASSWORD}
|
||||
- TZ=${TZ:-Europe/Prague}
|
||||
- POLL_INTERVAL_SECONDS=${POLL_INTERVAL_SECONDS:-2}
|
||||
- START_WATTS=${START_WATTS:-100}
|
||||
- STOP_WATTS=${STOP_WATTS:-30}
|
||||
- CONFIRM_SAMPLES=${CONFIRM_SAMPLES:-2}
|
||||
- MAX_GAP_SECONDS=${MAX_GAP_SECONDS:-20}
|
||||
- SAMPLE_RETENTION_DAYS=${SAMPLE_RETENTION_DAYS:-30}
|
||||
- DATABASE_PATH=/data/pump-monitor.sqlite3
|
||||
- DASHBOARD_USERNAME=${DASHBOARD_USERNAME:-water-monitor}
|
||||
- DASHBOARD_PASSWORD=${DASHBOARD_PASSWORD}
|
||||
volumes:
|
||||
- pump-monitor-data:/data
|
||||
|
||||
volumes:
|
||||
pump-monitor-data:
|
||||
@@ -0,0 +1,3 @@
|
||||
fastapi==0.141.1
|
||||
python-kasa==0.10.2
|
||||
uvicorn==0.52.4
|
||||
@@ -0,0 +1,107 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from app.detector import CycleDetector, PowerSample
|
||||
from app.storage import Storage, to_db_time
|
||||
|
||||
|
||||
def at(second: int) -> datetime:
|
||||
return datetime(2026, 9, 5, 10, 0, second, tzinfo=UTC)
|
||||
|
||||
|
||||
def make_storage(tmp_path) -> Storage:
|
||||
storage = Storage(str(tmp_path / "monitor.sqlite3"))
|
||||
storage.open()
|
||||
return storage
|
||||
|
||||
|
||||
def feed(storage: Storage, detector: CycleDetector, second: int, watts: float):
|
||||
sampled_at = at(second)
|
||||
storage.record_sample(sampled_at, watts, 230.0, 0.0, True)
|
||||
return detector.process(PowerSample(sampled_at, watts))
|
||||
|
||||
|
||||
def test_records_confirmed_cycle_using_first_threshold_crossings(tmp_path):
|
||||
storage = make_storage(tmp_path)
|
||||
detector = CycleDetector(storage, start_watts=100, stop_watts=30, confirm_samples=2)
|
||||
|
||||
feed(storage, detector, 0, 2)
|
||||
assert feed(storage, detector, 1, 120) == []
|
||||
started = feed(storage, detector, 2, 710)
|
||||
assert started[0]["type"] == "started"
|
||||
assert detector.running
|
||||
|
||||
feed(storage, detector, 3, 700)
|
||||
assert feed(storage, detector, 4, 10) == []
|
||||
stopped = feed(storage, detector, 5, 3)
|
||||
|
||||
assert stopped[0]["type"] == "stopped"
|
||||
cycle = stopped[0]["cycle"]
|
||||
assert cycle["started_at"] == "2026-09-05T10:00:01.000Z"
|
||||
assert cycle["ended_at"] == "2026-09-05T10:00:04.000Z"
|
||||
assert cycle["duration_seconds"] == 3.0
|
||||
assert cycle["maximum_watts"] == 710.0
|
||||
assert cycle["incomplete_start"] == 0
|
||||
assert cycle["incomplete_end"] == 0
|
||||
|
||||
|
||||
def test_hysteresis_ignores_noise_between_thresholds(tmp_path):
|
||||
storage = make_storage(tmp_path)
|
||||
detector = CycleDetector(storage, start_watts=100, stop_watts=30, confirm_samples=2)
|
||||
|
||||
feed(storage, detector, 0, 0)
|
||||
feed(storage, detector, 1, 105)
|
||||
feed(storage, detector, 2, 80)
|
||||
assert not detector.running
|
||||
|
||||
feed(storage, detector, 3, 150)
|
||||
feed(storage, detector, 4, 160)
|
||||
assert detector.running
|
||||
feed(storage, detector, 5, 45)
|
||||
assert detector.running
|
||||
feed(storage, detector, 6, 25)
|
||||
feed(storage, detector, 7, 40)
|
||||
assert detector.running
|
||||
|
||||
|
||||
def test_initial_running_state_and_interruption_are_marked_partial(tmp_path):
|
||||
storage = make_storage(tmp_path)
|
||||
detector = CycleDetector(storage, start_watts=100, stop_watts=30, confirm_samples=2)
|
||||
|
||||
feed(storage, detector, 0, 600)
|
||||
feed(storage, detector, 1, 610)
|
||||
event = detector.interrupt(at(2))
|
||||
|
||||
assert event is not None
|
||||
cycle = event["cycle"]
|
||||
assert cycle["incomplete_start"] == 1
|
||||
assert cycle["incomplete_end"] == 1
|
||||
|
||||
|
||||
def test_open_cycle_summary_uses_current_time_not_end_of_day(tmp_path, monkeypatch):
|
||||
storage = make_storage(tmp_path)
|
||||
start = datetime.now(UTC) - timedelta(seconds=12)
|
||||
storage.start_cycle(start)
|
||||
summary = storage.summary_between(start - timedelta(minutes=1), start + timedelta(days=1))
|
||||
|
||||
assert 11 <= summary["runtime_seconds"] <= 14
|
||||
|
||||
|
||||
def test_history_downsampling_preserves_peak_and_cycle_samples(tmp_path):
|
||||
storage = make_storage(tmp_path)
|
||||
start = at(0)
|
||||
for index in range(120):
|
||||
sampled_at = start + timedelta(seconds=index)
|
||||
watts = 1700.0 if index == 61 else (1100.0 if 60 <= index <= 64 else 0.0)
|
||||
storage.record_sample(sampled_at, watts, 230.0, 0.0, True)
|
||||
|
||||
history = storage.sample_history(
|
||||
start,
|
||||
start + timedelta(seconds=119),
|
||||
max_points=20,
|
||||
preserve_ranges=[(start + timedelta(seconds=60), start + timedelta(seconds=64))],
|
||||
)
|
||||
|
||||
retained = {row["sampled_at"]: row["watts"] for row in history}
|
||||
assert max(retained.values()) == 1700.0
|
||||
for second in range(60, 65):
|
||||
assert to_db_time(start + timedelta(seconds=second)) in retained
|
||||
Reference in New Issue
Block a user