Initial commit: ShellyDash dashboard fotovoltaico

Flask app con gauge Chart.js custom, polling Shelly Cloud API v2,
storico SQLite, due widget gauge (produzione/consumo) con scala graduata.
This commit is contained in:
truetype committed 2026-07-14 20:38:18 +02:00
commit b078b850f8
9 files changed
+1039

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
venv/
*.pyc
__pycache__/
.env
data/*.db
+86
View File
@@ -0,0 +1,86 @@
# shellydash — AGENTS.md
## Quick start
```bash
cd /home/codex/shellydash
venv/bin/python3 app.py # avvia su 0.0.0.0:5050
```
Il `.env` carica automaticamente via `load_dotenv()` in `app.py` e `poller.py`.
## Stack
- Python 3.14, Flask 3.1, SQLite, Chart.js 4.4 (CDN)
- Venv: `venv/` (già creato)
- Dipendenze: `flask`, `requests`, `python-dotenv`
## Architettura
| File | Ruolo |
|---|---|
| `app.py` | Entrypoint Flask, 6 route API + serve `templates/index.html` |
| `shelly_client.py` | Chiamate HTTP POST a Shelly Cloud Control API v2 |
| `database.py` | SQLite with WAL mode, schema `readings`, energy aggregations |
| `poller.py` | Thread background: poll Shelly ogni 15s → insert DB + cache `latest_parsed` |
| `templates/index.html` | Dashboard HTML + JS (Chart.js gauge custom plugin, storico, energia) |
### Flusso dati
```
Shelly Cloud ← poller.poll_once() (ogni 15s) → database.py (SQLite)
→ app._latest_parsed (dict, in-memory)
→ /api/status (da cache, non DB)
→ /api/history (da DB)
```
### API endpoints
| Route | Descrizione | Fonte dati |
|---|---|---|
| `GET /api/status` | Letture live (produzione, consumo, delta, V, A, PF, temp) | cache poller |
| `GET /api/history?hours=24` | Array timestamp → produzione/consumo | DB |
| `GET /api/energy/today` | Energia odierna (prodotta, consumata, immessa) | DB (differenza energy counter) |
| `GET /api/energy/monthly` | Breakdown giornaliero mese corrente | DB |
| `GET /api/delta` | Surplus/deficit booleano per automazioni | cache poller |
## Shelly EM — assetto canali
- **Un solo device** con ID `d885ac0b5860` (Shelly EM Gen3, modello S3EM-002CXCEU)
- `em1:0` → produzione pannelli (pinza sull'uscita inverter)
- `em1:1` → consumo casa/rete (pinza sull'ingresso inverter, valori negativi = prelievo)
- `consumption_w` nelle API = `abs(ch1)` quando negativo
- Auth via `auth_key` (parametro query `?auth_key=...`), **non** email/password
- Endpoint: `https://shelly-255-eu.shelly.cloud/v2/devices/api/{action}`
## Convenzioni codice
- `from __future__ import annotations` in ogni .py
- Type hints ovunque
- Logging via `logging.getLogger("shellydash.*")`
- `.env` non tracciato in git (`.gitignore` lo esclude)
## Frontend (index.html)
- **Non** usa framework JS — vanilla JS con Chart.js via CDN
- Gauge semi-circolare con lancetta: plugin Chart.js custom (`gaugePlugin`), **non** libreria esterna
- Due widget gauge: `#gaugeProd` (produzione, max 6000W, verde) e `#gaugeCons` (consumo, max 3000W, ambra)
- Scala graduata con tacche maggiori (1000W) e minori (500W) disegnate su canvas
- Auto-refresh ogni 15s via `setInterval(refreshAll, 15000)`
- UI in italiano, tema scuro (#0f172a sfondo)
## Comandi utili
```bash
# Avviare
venv/bin/python3 app.py
# Test API singola
curl -s http://localhost:5050/api/status | python3 -m json.tool
# Verificare import (senza avviare server)
venv/bin/python3 -c "from app import app; print('ok')"
# Installare nuove dipendenze
venv/bin/pip install <package>
# E aggiungere a requirements.txt
```
## Note operative
- Il poller parte in un thread **dopo** il primo `poller.poll_once()`. Serve almeno un poll riuscito prima che `/api/status` risponda.
- Il DB è in `data/shellydash.db` — escluso da git.
- `POLL_INTERVAL` in `.env` (default 15s).
- Non ci sono test (repo in fase iniziale).
- Il device Shelly deve essere online e connesso al cloud.
+151
View File
@@ -0,0 +1,151 @@
"""ShellyDash — Flask application with REST API + dashboard."""
from __future__ import annotations
import logging
import os
from pathlib import Path
from dotenv import load_dotenv
from flask import Flask, jsonify, render_template, request, send_from_directory
from database import Database
from poller import ShellyPoller
from shelly_client import ShellyClient
load_dotenv()
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
SERVER = os.getenv("SHELLY_SERVER", "")
AUTH_KEY = os.getenv("SHELLY_AUTH_KEY", "")
DEVICE_ID = os.getenv("SHELLY_DEVICE_ID", "")
FLASK_HOST = os.getenv("FLASK_HOST", "0.0.0.0")
FLASK_PORT = int(os.getenv("FLASK_PORT", "5050"))
# ── Globals ────────────────────────────────────────────────────────
db = Database()
client = ShellyClient(SERVER, AUTH_KEY, DEVICE_ID)
poller = ShellyPoller(client, db)
app = Flask(__name__, static_folder="static", template_folder="templates")
# ── Frontend routes ────────────────────────────────────────────────
@app.route("/")
def index():
return render_template("index.html")
@app.route("/static/<path:filename>")
def static_files(filename):
return send_from_directory(Path(app.root_path) / "static", filename)
# ── API routes ─────────────────────────────────────────────────────
@app.route("/api/status")
def api_status():
"""Current real-time readings."""
live = poller.latest_parsed
if not live:
return jsonify({"ok": False, "error": "No data yet"}), 503
ch0_power = live.get("ch0_act_power") or 0
ch1_power = live.get("ch1_act_power") or 0
consumption = abs(ch1_power) if ch1_power < 0 else ch1_power
delta = ch0_power - consumption
return jsonify({
"ok": True,
"ts": live.get("unixtime"),
"production_w": round(ch0_power, 1),
"consumption_w": round(consumption, 1),
"delta_w": round(delta, 1),
"voltage": live.get("ch0_voltage"),
"ch0_current": live.get("ch0_current"),
"ch1_current": live.get("ch1_current"),
"ch0_pf": live.get("ch0_pf"),
"ch1_pf": live.get("ch1_pf"),
"temperature": live.get("temperature"),
"wifi_rssi": live.get("wifi_rssi"),
"online": live.get("online"),
})
@app.route("/api/history")
def api_history():
"""Historical data. Query param: hours (default 24)."""
hours = int(request.args.get("hours", 24))
rows = db.get_history(hours)
return jsonify({
"ok": True,
"hours": hours,
"data": [
{
"ts": r["ts"],
"production_w": r["ch0_act_power"],
"consumption_w": abs(r["ch1_act_power"]) if (r["ch1_act_power"] or 0) < 0 else r["ch1_act_power"],
"voltage": r["ch0_voltage"],
"ch0_current": r["ch0_current"],
"ch1_current": r["ch1_current"],
}
for r in rows
],
})
@app.route("/api/energy/today")
def api_energy_today():
"""Today's energy totals."""
today = db.get_energy_today()
return jsonify({
"ok": True,
"production_kwh": round((today.get("ch0_energy") or 0) / 1000, 3),
"production_returned_kwh": round((today.get("ch0_ret") or 0) / 1000, 3),
"consumption_kwh": round((today.get("ch1_energy") or 0) / 1000, 3),
"grid_returned_kwh": round((today.get("ch1_ret") or 0) / 1000, 3),
})
@app.route("/api/energy/monthly")
def api_energy_monthly():
"""Monthly energy breakdown by day."""
days = db.get_monthly_energy()
return jsonify({
"ok": True,
"data": [
{
"day": d["day"],
"production_kwh": round((d["ch0_prod"] or 0) / 1000, 2),
"consumption_kwh": round((d["ch1_cons"] or 0) / 1000, 2),
}
for d in days
],
})
@app.route("/api/delta")
def api_delta():
"""Surplus / deficit value for automations."""
live = poller.latest_parsed
if not live:
return jsonify({"ok": False, "error": "No data"}), 503
prod = live.get("ch0_act_power") or 0
ch1 = live.get("ch1_act_power") or 0
cons = abs(ch1) if ch1 < 0 else ch1
delta = prod - cons
return jsonify({
"ok": True,
"delta_w": round(delta, 1),
"production_w": round(prod, 1),
"consumption_w": round(cons, 1),
"surplus": delta > 0,
})
# ── Main ───────────────────────────────────────────────────────────
if __name__ == "__main__":
poller.poll_once()
poller.start()
app.run(host=FLASK_HOST, port=FLASK_PORT, debug=False)
+151
View File
@@ -0,0 +1,151 @@
"""SQLite database manager for ShellyDash."""
from __future__ import annotations
import sqlite3
import time
from datetime import datetime, timezone
from pathlib import Path
DB_PATH = Path(__file__).parent / "data" / "shellydash.db"
class Database:
"""Manage readings storage and queries."""
def __init__(self, db_path: str | Path = DB_PATH):
self.db_path = Path(db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_schema()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(str(self.db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def _init_schema(self):
with self._connect() as conn:
conn.executescript("""
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts INTEGER NOT NULL, -- unix timestamp
ch0_act_power REAL,
ch0_aprt_power REAL,
ch0_current REAL,
ch0_voltage REAL,
ch0_pf REAL,
ch0_total_act_energy REAL,
ch0_total_ret_energy REAL,
ch1_act_power REAL,
ch1_aprt_power REAL,
ch1_current REAL,
ch1_voltage REAL,
ch1_pf REAL,
ch1_total_act_energy REAL,
ch1_total_ret_energy REAL,
switch_output INTEGER,
temperature REAL,
wifi_rssi INTEGER,
online INTEGER,
uptime INTEGER
);
CREATE INDEX IF NOT EXISTS idx_readings_ts ON readings(ts);
""")
def insert_reading(self, r: dict) -> int | None:
ts = int(r.get("unixtime") or time.time())
with self._connect() as conn:
cur = conn.execute(
"""INSERT INTO readings (
ts, ch0_act_power, ch0_aprt_power, ch0_current, ch0_voltage,
ch0_pf, ch0_total_act_energy, ch0_total_ret_energy,
ch1_act_power, ch1_aprt_power, ch1_current, ch1_voltage,
ch1_pf, ch1_total_act_energy, ch1_total_ret_energy,
switch_output, temperature, wifi_rssi, online, uptime
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
ts,
r.get("ch0_act_power"),
r.get("ch0_aprt_power"),
r.get("ch0_current"),
r.get("ch0_voltage"),
r.get("ch0_pf"),
r.get("ch0_total_act_energy"),
r.get("ch0_total_ret_energy"),
r.get("ch1_act_power"),
r.get("ch1_aprt_power"),
r.get("ch1_current"),
r.get("ch1_voltage"),
r.get("ch1_pf"),
r.get("ch1_total_act_energy"),
r.get("ch1_total_ret_energy"),
1 if r.get("switch_output") else 0,
r.get("temperature"),
r.get("wifi_rssi"),
1 if r.get("online") else 0,
r.get("uptime"),
),
)
return cur.lastrowid
def get_latest(self) -> dict | None:
"""Return the most recent reading."""
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM readings ORDER BY ts DESC LIMIT 1"
).fetchone()
return dict(row) if row else None
def get_history(self, hours: int = 24) -> list[dict]:
"""Return readings for the last N hours, sampled ~ every 60s."""
since = int(time.time()) - hours * 3600
with self._connect() as conn:
rows = conn.execute(
"""SELECT * FROM readings
WHERE ts >= ?
ORDER BY ts ASC""",
(since,),
).fetchall()
return [dict(r) for r in rows]
def get_energy_today(self) -> dict:
"""Return total energy produced/consumed today."""
t = datetime.now(timezone.utc)
start_of_day = int(t.replace(hour=0, minute=0, second=0, microsecond=0).timestamp())
with self._connect() as conn:
row = conn.execute(
"""SELECT
MAX(ch0_total_act_energy) - MIN(ch0_total_act_energy) AS ch0_energy,
MAX(ch0_total_ret_energy) - MIN(ch0_total_ret_energy) AS ch0_ret,
MAX(ch1_total_act_energy) - MIN(ch1_total_act_energy) AS ch1_energy,
MAX(ch1_total_ret_energy) - MIN(ch1_total_ret_energy) AS ch1_ret
FROM readings WHERE ts >= ?""",
(start_of_day,),
).fetchone()
return dict(row) if row else {}
def get_monthly_energy(self) -> list[dict]:
"""Return daily energy sums for the current month."""
t = datetime.now(timezone.utc)
start_of_month = int(t.replace(day=1, hour=0, minute=0, second=0, microsecond=0).timestamp())
with self._connect() as conn:
rows = conn.execute(
"""SELECT
date(ts, 'unixepoch') AS day,
MAX(ch0_total_act_energy) - MIN(ch0_total_act_energy) AS ch0_prod,
MAX(ch1_total_act_energy) - MIN(ch1_total_act_energy) AS ch1_cons
FROM readings
WHERE ts >= ?
GROUP BY day
ORDER BY day ASC""",
(start_of_month,),
).fetchall()
return [dict(r) for r in rows]
def cleanup_old(self, days: int = 365):
"""Delete readings older than N days."""
cutoff = int(time.time()) - days * 86400
with self._connect() as conn:
conn.execute("DELETE FROM readings WHERE ts < ?", (cutoff,))
+78
View File
@@ -0,0 +1,78 @@
"""Background poller that fetches Shelly data periodically."""
from __future__ import annotations
import logging
import os
import threading
import time
from pathlib import Path
from dotenv import load_dotenv
from database import Database
from shelly_client import ShellyClient, ShellyError
load_dotenv()
logger = logging.getLogger("shellydash.poller")
POLL_INTERVAL = int(os.getenv("POLL_INTERVAL", "15"))
class ShellyPoller:
"""Periodically fetches device status and stores readings."""
def __init__(
self,
client: ShellyClient,
db: Database,
interval: int = POLL_INTERVAL,
):
self.client = client
self.db = db
self.interval = interval
self._running = False
self._thread: threading.Thread | None = None
self._latest_raw: dict = {}
self._latest_parsed: dict = {}
@property
def latest_parsed(self) -> dict:
return self._latest_parsed
@property
def latest_raw(self) -> dict:
return self._latest_raw
def start(self):
self._running = True
self._thread = threading.Thread(target=self._loop, daemon=True)
self._thread.start()
logger.info("Poller started (interval=%ds)", self.interval)
def stop(self):
self._running = False
def poll_once(self) -> bool:
try:
status = self.client.get_device_status()
if not status:
logger.warning("Empty status from Shelly API")
return False
self._latest_raw = status
parsed = self.client.parse_readings(status)
self._latest_parsed = parsed
self.db.insert_reading(parsed)
return True
except ShellyError as exc:
logger.error("Poll error: %s", exc)
return False
except Exception as exc:
logger.exception("Unexpected poll error: %s", exc)
return False
def _loop(self):
while self._running:
self.poll_once()
time.sleep(self.interval)
+2
View File
@@ -0,0 +1,2 @@
flask>=3.0
requests>=2.31
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

+104
View File
@@ -0,0 +1,104 @@
"""Shelly Cloud API client."""
from __future__ import annotations
import time
from typing import Any
import requests
class ShellyError(Exception):
"""Shelly API error."""
class ShellyClient:
"""Client for Shelly Cloud Control API v2."""
def __init__(
self,
server: str,
auth_key: str,
device_id: str,
timeout: float = 10,
):
self.server = server.rstrip("/")
self.auth_key = auth_key
self.device_id = device_id
self.timeout = timeout
self._session = requests.Session()
def _post(self, path: str, body: dict) -> Any:
"""POST JSON to API endpoint with auth_key as query param."""
url = f"{self.server}/v2/devices/api/{path}"
params = {"auth_key": self.auth_key}
try:
resp = self._session.post(
url, json=body, params=params, timeout=self.timeout
)
except requests.exceptions.ConnectionError as exc:
raise ShellyError(f"Connection error: {exc}") from exc
except requests.exceptions.Timeout as exc:
raise ShellyError(f"Timeout: {exc}") from exc
if resp.status_code == 429:
time.sleep(2)
return self._post(path, body)
if resp.status_code == 401:
raise ShellyError("Authentication failed — check auth_key")
if not resp.ok:
raise ShellyError(f"HTTP {resp.status_code}: {resp.text[:200]}")
if not resp.content:
return {}
return resp.json()
def get_device_status(self) -> dict:
"""Fetch full status of the device."""
data = self._post("get", {
"ids": [self.device_id],
"select": ["status"],
})
if isinstance(data, list) and len(data) > 0:
return data[0]
return {}
def parse_readings(self, status: dict) -> dict:
"""Extract useful readings from device status."""
s = status.get("status", status)
em1_0 = s.get("em1:0", {})
em1_1 = s.get("em1:1", {})
em1data_0 = s.get("em1data:0", {})
em1data_1 = s.get("em1data:1", {})
switch = s.get("switch:0", {})
wifi = s.get("wifi", {})
return {
# Canale 0 — produzione pannelli → inverter
"ch0_act_power": em1_0.get("act_power"),
"ch0_aprt_power": em1_0.get("aprt_power"),
"ch0_current": em1_0.get("current"),
"ch0_voltage": em1_0.get("voltage"),
"ch0_pf": em1_0.get("pf"),
"ch0_total_act_energy": em1data_0.get("total_act_energy"),
"ch0_total_ret_energy": em1data_0.get("total_act_ret_energy"),
# Canale 1 — inverter → casa/rete
"ch1_act_power": em1_1.get("act_power"),
"ch1_aprt_power": em1_1.get("aprt_power"),
"ch1_current": em1_1.get("current"),
"ch1_voltage": em1_1.get("voltage"),
"ch1_pf": em1_1.get("pf"),
"ch1_total_act_energy": em1data_1.get("total_act_energy"),
"ch1_total_ret_energy": em1data_1.get("total_act_ret_energy"),
# Switch / stato dispositivo
"switch_output": switch.get("output"),
"temperature": switch.get("temperature", {}).get("tC"),
"wifi_rssi": wifi.get("rssi"),
"online": s.get("online", status.get("online")),
"unixtime": s.get("sys", {}).get("unixtime"),
"uptime": s.get("sys", {}).get("uptime"),
}
+462
View File
@@ -0,0 +1,462 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ShellyDash — Monitoraggio Fotovoltaico</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background:#0f172a;color:#e2e8f0;min-height:100vh;
}
.header{
background:#1e293b;padding:16px 24px;display:flex;align-items:center;
justify-content:space-between;border-bottom:1px solid #334155;
}
.header h1{font-size:20px;font-weight:600;display:flex;align-items:center;gap:10px}
.header h1 span{color:#38bdf8}
.badge{
display:inline-block;padding:4px 12px;border-radius:12px;font-size:12px;font-weight:600;
}
.badge.online{background:#166534;color:#86efac}
.badge.offline{background:#7f1d1d;color:#fca5a5}
.container{max-width:1400px;margin:0 auto;padding:20px}
/* Gauge row */
.gauge-row{display:flex;gap:20px;margin-bottom:20px;flex-wrap:wrap}
.gauge-card{
flex:1;min-width:300px;background:#1e293b;border-radius:16px;padding:16px 20px 10px;
border:1px solid #334155;display:flex;flex-direction:column;align-items:center;
}
.gauge-card h2{font-size:13px;color:#94a3b8;margin-bottom:2px;text-transform:uppercase;letter-spacing:1px}
.gauge-wrapper{width:100%;max-width:360px;margin:0 auto}
/* Info cards */
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px}
.card{
background:#1e293b;border-radius:12px;padding:14px;border:1px solid #334155;text-align:center;
}
.card .label{font-size:11px;text-transform:uppercase;color:#64748b;letter-spacing:1px}
.card .num{font-size:24px;font-weight:700;margin-top:3px}
.card .num.green{color:#4ade80}
.card .num.blue{color:#38bdf8}
.card .num.amber{color:#fbbf24}
.card .num.red{color:#f87171}
.card .num.purple{color:#a78bfa}
/* History chart */
.chart-section{background:#1e293b;border-radius:16px;padding:20px;border:1px solid #334155;margin-bottom:20px}
.chart-section h2{font-size:13px;color:#94a3b8;margin-bottom:10px;text-transform:uppercase;letter-spacing:1px}
.chart-section canvas{max-height:280px}
/* Energy section */
.energy-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}
.energy-card{background:#1e293b;border-radius:12px;padding:14px;border:1px solid #334155;text-align:center}
.energy-card .label{font-size:11px;color:#64748b;text-transform:uppercase}
.energy-card .num{font-size:22px;font-weight:700;margin-top:3px}
.energy-card .num.green{color:#4ade80}
.energy-card .num.amber{color:#fbbf24}
.energy-card .num.blue{color:#38bdf8}
/* Delta highlight */
.delta-highlight{
background:linear-gradient(135deg,#1e293b,#0f172a);
border:2px solid #334155;border-radius:16px;padding:14px 24px;
display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;
}
.delta-highlight .label{font-size:12px;color:#94a3b8}
.delta-highlight .value{font-size:38px;font-weight:700}
.delta-highlight .value.positive{color:#4ade80}
.delta-highlight .value.negative{color:#f87171}
.delta-highlight .sub{font-size:13px;color:#64748b}
.loading{text-align:center;padding:60px;color:#64748b;font-size:18px}
/* Responsive */
@media(max-width:768px){
.header h1{font-size:15px}
.gauge-card{min-width:100%}
.delta-highlight{flex-direction:column;gap:8px;text-align:center}
.delta-highlight .value{font-size:28px}
}
.btn-range{
background:#334155;color:#94a3b8;border:none;padding:6px 14px;border-radius:8px;
font-size:12px;font-weight:600;cursor:pointer;transition:all 0.2s;
}
.btn-range:hover{background:#475569;color:#e2e8f0}
.btn-range.active{background:#38bdf8;color:#0f172a}
</style>
</head>
<body>
<div class="header">
<h1>⚡ <span>ShellyDash</span> — Fotovoltaico</h1>
<div><span id="statusBadge" class="badge offline">⏳ Caricamento...</span></div>
</div>
<div class="container" id="app">
<div class="loading" id="loading">Caricamento dati...</div>
<div id="dashboard" style="display:none">
<!-- Delta highlight -->
<div class="delta-highlight" id="deltaBox">
<div>
<div class="label">SURPLUS / DEFICIT</div>
<div class="value" id="deltaValue">--</div>
<div class="sub">Produzione − Consumo</div>
</div>
<div style="text-align:right" id="deltaMeta">
<div class="label">Produzione</div>
<div style="font-size:20px;font-weight:600;color:#4ade80" id="prodValue">-- W</div>
<div class="label" style="margin-top:5px">Consumo</div>
<div style="font-size:20px;font-weight:600;color:#fbbf24" id="consValue">-- W</div>
</div>
</div>
<!-- Two gauge widgets -->
<div class="gauge-row">
<div class="gauge-card">
<h2>Produzione Pannelli</h2>
<div class="gauge-wrapper">
<canvas id="gaugeProd"></canvas>
</div>
</div>
<div class="gauge-card">
<h2>Consumo Casa</h2>
<div class="gauge-wrapper">
<canvas id="gaugeCons"></canvas>
</div>
</div>
</div>
<!-- Cards row -->
<div class="cards">
<div class="card">
<div class="label">Tensione</div>
<div class="num blue" id="voltageCard">-- V</div>
</div>
<div class="card">
<div class="label">Fattore Potenza</div>
<div class="num purple" id="pfCard">--</div>
</div>
<div class="card">
<div class="label">Corrente Fase 1</div>
<div class="num" id="ch0currentCard" style="color:#94a3b8">-- A</div>
</div>
<div class="card">
<div class="label">Corrente Fase 2</div>
<div class="num" id="ch1currentCard" style="color:#94a3b8">-- A</div>
</div>
</div>
<!-- Energy + History -->
<div class="chart-section">
<h2>Energia Oggi</h2>
<div class="energy-grid" style="margin-bottom:16px">
<div class="energy-card">
<div class="label">Prodotta</div>
<div class="num green" id="energyProd">-- kWh</div>
</div>
<div class="energy-card">
<div class="label">Consumata</div>
<div class="num amber" id="energyCons">-- kWh</div>
</div>
<div class="energy-card">
<div class="label">Immessa in Rete</div>
<div class="num blue" id="energyGrid">-- kWh</div>
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
<h2>Storico</h2>
<div style="display:flex;gap:8px">
<button class="btn-range active" data-hours="24">24h</button>
<button class="btn-range" data-hours="72">3gg</button>
<button class="btn-range" data-hours="168">7gg</button>
</div>
</div>
<canvas id="historyChart"></canvas>
</div>
</div>
</div>
<script>
// ── Gauge Plugin (semi-circular needle + scale) ───────────────────
// Accepts config via chart.options.gaugeConfig
const gaugePlugin = {
id: 'gaugePlugin',
afterDraw(chart) {
const cfg = chart.options.gaugeConfig || {};
const {ctx, chartArea: {top, bottom, left, right}} = chart;
const value = cfg.value || 0;
const maxVal = cfg.max || 6000;
const color = cfg.color || '#4ade80';
const unit = cfg.unit || 'W';
const labelText = cfg.label || '';
const centerX = (left + right) / 2;
const centerY = bottom - 8;
const radius = Math.min(right - left, (bottom - top) * 2) / 2 - 14;
// ── Draw scale ticks ──
const majorStep = maxVal <= 3000 ? 500 : 1000;
const minorStep = majorStep / 2;
const startAngle = Math.PI; // 180° (left)
const endAngle = 2 * Math.PI; // 360° (right)
ctx.save();
// Major ticks + numbers
for (let v = 0; v <= maxVal; v += majorStep) {
const frac = v / maxVal;
const angle = startAngle + frac * Math.PI;
const x1 = centerX + (radius - 8) * Math.cos(angle);
const y1 = centerY + (radius - 8) * Math.sin(angle);
const x2 = centerX + (radius - 20) * Math.cos(angle);
const y2 = centerY + (radius - 20) * Math.sin(angle);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = '#64748b';
ctx.lineWidth = 2;
ctx.stroke();
// Number label
const lx = centerX + (radius - 32) * Math.cos(angle);
const ly = centerY + (radius - 32) * Math.sin(angle);
ctx.fillStyle = '#94a3b8';
ctx.font = '11px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const displayNum = v >= 1000 ? (v/1000) + 'k' : v;
ctx.fillText(displayNum, lx, ly);
}
// Minor ticks
for (let v = minorStep; v < maxVal; v += minorStep) {
// skip if also a major tick
if (v % majorStep === 0) continue;
const frac = v / maxVal;
const angle = startAngle + frac * Math.PI;
const x1 = centerX + (radius - 6) * Math.cos(angle);
const y1 = centerY + (radius - 6) * Math.sin(angle);
const x2 = centerX + (radius - 14) * Math.cos(angle);
const y2 = centerY + (radius - 14) * Math.sin(angle);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.strokeStyle = '#475569';
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.restore();
// ── Needle ──
const needleAngle = startAngle + (Math.min(value, maxVal) / maxVal) * Math.PI;
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(needleAngle);
ctx.beginPath();
ctx.moveTo(0, -5);
ctx.lineTo(radius - 16, 0);
ctx.lineTo(0, 5);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
ctx.restore();
// ── Center dot ──
ctx.beginPath();
ctx.arc(centerX, centerY, 7, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
ctx.strokeStyle = '#1e293b';
ctx.lineWidth = 2.5;
ctx.stroke();
// ── Value text ──
ctx.fillStyle = '#e2e8f0';
ctx.font = 'bold 26px -apple-system, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
const displayVal = value >= 1000 ? (value/1000).toFixed(1) + ' kW' : Math.round(value) + ' ' + unit;
ctx.fillText(displayVal, centerX, centerY + 50);
if (labelText) {
ctx.fillStyle = '#64748b';
ctx.font = '12px -apple-system, sans-serif';
ctx.textBaseline = 'top';
ctx.fillText(labelText, centerX, centerY + 52);
}
}
};
// ── State ─────────────────────────────────────────────────────────
let historyChart = null;
let rangeHours = 24;
let gaugeCharts = {};
// ── Init gauge ────────────────────────────────────────────────────
function initGauge(canvasId, maxVal) {
const canvas = document.getElementById(canvasId);
if (!canvas) return null;
const parent = canvas.parentElement;
canvas.width = parent.clientWidth || 340;
canvas.height = Math.min(canvas.width * 0.52, 220);
// dummy data — actual drawing is in the plugin
return new Chart(canvas.getContext('2d'), {
type: 'doughnut',
data: {
datasets: [{ data: [1], backgroundColor: ['transparent'], borderWidth: 0 }]
},
options: {
responsive: true,
maintainAspectRatio: false,
cutout: '92%',
plugins: { tooltip: { enabled: false }, legend: { display: false } },
gaugeConfig: { value: 0, max: maxVal, color: '#4ade80', unit: 'W', label: '' },
},
plugins: [gaugePlugin]
});
}
function updateGauge(chart, value, maxVal, color, label) {
if (!chart) return;
chart.options.gaugeConfig.value = value;
chart.options.gaugeConfig.max = maxVal;
if (color) chart.options.gaugeConfig.color = color;
if (label) chart.options.gaugeConfig.label = label;
chart.update();
}
// ── Init history chart ────────────────────────────────────────────
function initHistoryChart() {
const canvas = document.getElementById('historyChart');
if (!canvas) return null;
const ctx = canvas.getContext('2d');
return new Chart(ctx, {
type: 'line',
data: { labels: [], datasets: [
{ label: 'Produzione', data: [], borderColor: '#4ade80', backgroundColor: 'rgba(74,222,128,0.08)', fill: true, tension: 0.3, pointRadius: 0 },
{ label: 'Consumo', data: [], borderColor: '#fbbf24', backgroundColor: 'rgba(251,191,36,0.08)', fill: true, tension: 0.3, pointRadius: 0 },
]},
options: {
responsive: true, maintainAspectRatio: false,
interaction: { mode: 'index', intersect: false },
plugins: { legend: { labels: { color: '#94a3b8', boxWidth: 12, padding: 12 } } },
scales: {
x: { ticks: { color: '#64748b', maxTicksLimit: 10 }, grid: { color: '#1e293b' } },
y: { ticks: { color: '#64748b' }, grid: { color: '#334155' }, beginAtZero: true }
}
}
});
}
function updateHistoryChart(chart, data) {
if (!chart || !data || !data.length) return;
const labels = data.map(d => {
const t = new Date(d.ts * 1000);
return t.toLocaleTimeString('it', {hour:'2-digit', minute:'2-digit'});
});
const prod = data.map(d => d.production_w ?? 0);
const cons = data.map(d => Math.abs(d.consumption_w ?? 0));
chart.data.labels = labels;
chart.data.datasets[0].data = prod;
chart.data.datasets[1].data = cons;
chart.update('none');
}
// ── Fetch & update ────────────────────────────────────────────────
async function fetchStatus() {
const r = await fetch('/api/status');
return r.json();
}
async function fetchHistory(hours) {
const r = await fetch(`/api/history?hours=${hours}`);
return r.json();
}
async function fetchEnergyToday() {
const r = await fetch('/api/energy/today');
return r.json();
}
function updateUI(status) {
if (!status.ok) return;
const p = status.production_w;
const c = status.consumption_w;
const delta = status.delta_w;
// Badge
const badge = document.getElementById('statusBadge');
badge.textContent = status.online ? '☁️ Online' : '⚠️ Offline';
badge.className = 'badge ' + (status.online ? 'online' : 'offline');
// Delta
document.getElementById('deltaValue').textContent = (delta >= 0 ? '+' : '') + delta.toFixed(0) + ' W';
document.getElementById('deltaValue').className = 'value ' + (delta >= 0 ? 'positive' : 'negative');
document.getElementById('prodValue').textContent = p.toFixed(0) + ' W';
document.getElementById('consValue').textContent = c.toFixed(0) + ' W';
// Cards
document.getElementById('voltageCard').textContent = (status.voltage || 0).toFixed(1) + ' V';
document.getElementById('pfCard').textContent = (status.ch0_pf || 0).toFixed(2);
document.getElementById('ch0currentCard').textContent = (status.ch0_current || 0).toFixed(2) + ' A';
document.getElementById('ch1currentCard').textContent = (status.ch1_current || 0).toFixed(2) + ' A';
// Gauges — production (max 6000W) + consumption (max 3000W)
updateGauge(gaugeCharts.prod, p, 6000, '#4ade80', 'Produzione');
updateGauge(gaugeCharts.cons, c, 3000, '#fbbf24', 'Consumo');
}
async function refreshAll() {
try {
const status = await fetchStatus();
if (status.ok || gaugeCharts.prod) {
document.getElementById('loading').style.display = 'none';
document.getElementById('dashboard').style.display = 'block';
if (status.ok) updateUI(status);
}
const history = await fetchHistory(rangeHours);
if (history.ok && historyChart) {
updateHistoryChart(historyChart, history.data);
}
const energy = await fetchEnergyToday();
if (energy.ok) {
document.getElementById('energyProd').textContent = (energy.production_kwh || 0).toFixed(2) + ' kWh';
document.getElementById('energyCons').textContent = (energy.consumption_kwh || 0).toFixed(2) + ' kWh';
document.getElementById('energyGrid').textContent = (energy.grid_returned_kwh || 0).toFixed(2) + ' kWh';
}
} catch(e) {
console.error('Refresh error:', e);
}
}
// ── Range buttons ─────────────────────────────────────────────────
document.addEventListener('click', function(e) {
const btn = e.target.closest('.btn-range');
if (!btn) return;
document.querySelectorAll('.btn-range').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
rangeHours = parseInt(btn.dataset.hours);
refreshAll();
});
// ── Start ─────────────────────────────────────────────────────────
gaugeCharts.prod = initGauge('gaugeProd', 6000);
gaugeCharts.cons = initGauge('gaugeCons', 3000);
historyChart = initHistoryChart();
refreshAll();
setInterval(refreshAll, 15000);
</script>
</body>
</html>