Files
shellydash/templates/index.html
T
truetype b078b850f8 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.
2026-07-14 20:38:18 +02:00

463 lines
17 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>