commit b078b850f854b845ae92ac18cfe8d90a9ddf882f Author: truetype Date: Tue Jul 14 20:38:18 2026 +0200 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. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58919c5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +venv/ +*.pyc +__pycache__/ +.env +data/*.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e0aa314 --- /dev/null +++ b/AGENTS.md @@ -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 +# 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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..69552f7 --- /dev/null +++ b/app.py @@ -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/") +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) diff --git a/database.py b/database.py new file mode 100644 index 0000000..3a8c3af --- /dev/null +++ b/database.py @@ -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,)) diff --git a/poller.py b/poller.py new file mode 100644 index 0000000..b215426 --- /dev/null +++ b/poller.py @@ -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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a0d407c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0 +requests>=2.31 diff --git a/shelly.PNG b/shelly.PNG new file mode 100644 index 0000000..c600b7f Binary files /dev/null and b/shelly.PNG differ diff --git a/shelly_client.py b/shelly_client.py new file mode 100644 index 0000000..7eaa053 --- /dev/null +++ b/shelly_client.py @@ -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"), + } diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..704826b --- /dev/null +++ b/templates/index.html @@ -0,0 +1,462 @@ + + + + + +ShellyDash — Monitoraggio Fotovoltaico + + + + +
+

⚡ ShellyDash — Fotovoltaico

+
⏳ Caricamento...
+
+
+
Caricamento dati...
+ +
+ + + +