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:
commit
b078b850f8
9 files changed
+1039
No files matched your search
@@ -0,0 +1,5 @@
|
||||
venv/
|
||||
*.pyc
|
||||
__pycache__/
|
||||
.env
|
||||
data/*.db
|
||||
@@ -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.
|
||||
@@ -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
@@ -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,))
|
||||
@@ -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)
|
||||
@@ -0,0 +1,2 @@
|
||||
flask>=3.0
|
||||
requests>=2.31
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 5.1 MiB |
@@ -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"),
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user