Flask app con gauge Chart.js custom, polling Shelly Cloud API v2, storico SQLite, due widget gauge (produzione/consumo) con scala graduata.
463 lines
17 KiB
HTML
463 lines
17 KiB
HTML
<!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>
|