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

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