Skip to content

Commit 44d6e3e

Browse files
author
Jeremy Ewing
committed
dashboard: fix image flicker and slow down refresh to 15s
- Only swap the battery icon src when the target image actually changes. - Preload all status images at page load and add long-term cache headers for immutable assets (CSS/SVG/JS/fonts) via CacheStaticFiles. - Stop rebuilding the entire detail view on every poll; keep the DOM and update only data-reg values. - Add computed pv_total_power / load_power registers so detail cards can update them without full innerHTML rebuilds. - Slow the fallback HTTP poll from 5s to 15s to match the ~4 snapshots/min rate.
1 parent 3d516cd commit 44d6e3e

2 files changed

Lines changed: 153 additions & 93 deletions

File tree

‎api/__init__.py‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,20 @@
2020
from pydantic import BaseModel
2121
from fastapi.middleware.cors import CORSMiddleware
2222
from fastapi.staticfiles import StaticFiles
23+
from starlette.staticfiles import StaticFiles as StarletteStaticFiles
24+
from starlette.types import Scope, Receive, Send
25+
26+
class CacheStaticFiles(StarletteStaticFiles):
27+
"""StaticFiles variant that adds long-term cache headers to immutable assets."""
28+
29+
IMMUTABLE = {".css", ".js", ".svg", ".png", ".jpg", ".jpeg", ".gif", ".woff2", ".woff", ".ttf", ".ico"}
30+
31+
async def get_response(self, path: str, scope: Scope) -> Any:
32+
response = await super().get_response(path, scope)
33+
ext = os.path.splitext(path)[1].lower()
34+
if ext in self.IMMUTABLE:
35+
response.headers.setdefault("Cache-Control", "public, max-age=31536000, immutable")
36+
return response
2337

2438
TZ = ZoneInfo("America/Chicago")
2539

@@ -801,4 +815,4 @@ def api_automation_log(limit: int = Query(50, ge=1, le=500)):
801815

802816
STATIC_DIR = Path(__file__).parent / "static"
803817
STATIC_DIR.mkdir(exist_ok=True)
804-
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
818+
app.mount("/", CacheStaticFiles(directory=str(STATIC_DIR), html=True), name="static")

‎api/static/index.html‎

Lines changed: 138 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@
88
<link rel="preload" href="https://fonts.googleapis.com/css2?family=Exo:ital,wght@0,100..900;1,100..900&display=swap" as="style" onload="this.onload=null;this.rel='stylesheet'">
99
<noscript><link href="https://fonts.googleapis.com/css2?family=Exo:ital,wght@0,100..900;1,100..900&display=swap" rel="stylesheet" type="text/css"></noscript>
1010
<link rel="stylesheet" href="/app.css">
11+
<link rel="preload" href="/images/inverter-ok.svg" as="image">
12+
<link rel="preload" href="/images/solar-forecast.svg" as="image">
13+
<link rel="preload" href="/images/grid-ok.svg" as="image">
14+
<link rel="preload" href="/images/battery-mid.svg" as="image">
15+
<link rel="preload" href="/images/battery-full.svg" as="image">
1116
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
1217
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns@3.0.0/dist/chartjs-adapter-date-fns.bundle.min.js"></script>
1318
<style>
@@ -379,6 +384,16 @@ <h3>Global Control</h3>
379384
let charts = {};
380385
let ws;
381386
let todayPeakPv = 0;
387+
let currentDetail = null;
388+
let detailRendered = false;
389+
const imageCache = {};
390+
function preloadImage(src) {
391+
if (imageCache[src]) return imageCache[src];
392+
const img = new Image();
393+
img.src = src;
394+
imageCache[src] = img;
395+
return img;
396+
}
382397

383398
function fmt(num, digits=1) {
384399
if (num === undefined || num === null || Number.isNaN(num)) return null;
@@ -393,10 +408,10 @@ <h3>Global Control</h3>
393408
const d = new Date(iso);
394409
return d.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});
395410
}
396-
function val(r, key) {
397-
if (!r || !r.registers) return undefined;
411+
function val(r, key, fallback) {
412+
if (!r || !r.registers) return fallback;
398413
const k = r.registers[key];
399-
return k ? k.value : undefined;
414+
return k && k.value !== undefined ? k.value : fallback;
400415
}
401416
async function fetchJson(url) {
402417
const res = await fetch(url);
@@ -425,6 +440,9 @@ <h3>Global Control</h3>
425440
const pv2 = val(r,'pv2_power') || 0;
426441
const pvTotal = pv1 + pv2;
427442
if (pvTotal > todayPeakPv) todayPeakPv = pvTotal;
443+
// Inject computed values so detail view can read them via data-reg
444+
if (!r.registers) r.registers = {};
445+
r.registers.pv_total_power = { value: pvTotal, unit: 'W' };
428446
setText('sa-solar-total', fmtInt(pvTotal) + ' W');
429447
setText('sa-solar-peak', fmtInt(todayPeakPv) + ' W');
430448

@@ -437,22 +455,34 @@ <h3>Global Control</h3>
437455
const batV = val(r,'battery_voltage');
438456
const soc = val(r,'soc');
439457
const batP = val(r,'battery_power') || ((val(r,'discharge_power')||0) - (val(r,'charge_power')||0));
458+
const gridIn = val(r,'grid_import_power') || 0;
459+
const gridOut = val(r,'grid_export_power') || 0;
460+
const loadPower = val(r,'load_power') || val(r,'total_load_power') || (pvTotal + gridIn - gridOut + ((val(r,'discharge_power')||0) - (val(r,'charge_power')||0)));
461+
r.registers.load_power = { value: loadPower, unit: 'W' };
440462
setText('sa-bat-v', batV !== undefined ? fmt(batV) + ' V' : null);
441463
setText('sa-bat-soc', soc !== undefined ? fmtInt(soc) + ' %' : null);
442464
setText('sa-bat-power', batP !== undefined ? fmtInt(batP) + ' W' : null);
443465

444-
// Battery icon based on SOC
466+
// Battery icon based on SOC — only swap if the target actually changes
445467
if (soc !== undefined) {
446468
const img = document.getElementById('img-battery');
447-
const level = soc < 25 ? 'battery-mid.svg' : (soc < 75 ? 'battery-mid.svg' : 'battery-full.svg');
448-
// Preload before swap
449-
const pre = new Image();
450-
pre.onload = () => { if (img) img.src = pre.src; };
451-
pre.src = '/images/' + level;
469+
if (img) {
470+
const level = soc < 25 ? 'battery-mid.svg' : (soc < 75 ? 'battery-mid.svg' : 'battery-full.svg');
471+
const nextSrc = '/images/' + level;
472+
const currentSrc = img.getAttribute('src');
473+
if (currentSrc !== nextSrc) {
474+
const pre = preloadImage(nextSrc);
475+
if (pre.complete) {
476+
img.src = nextSrc;
477+
} else {
478+
pre.onload = () => { img.src = nextSrc; };
479+
}
480+
}
481+
}
452482
}
453483

454484
// Gauges
455-
const load = val(r,'load_power') || val(r,'total_load_power') || (pvTotal + gridIn - gridOut + ((val(r,'discharge_power')||0) - (val(r,'charge_power')||0)));
485+
const load = val(r,'load_power');
456486
setGauge('load', load, 10000);
457487
setGauge('solar', pvTotal, 10000);
458488
setGauge('grid', gridIn - gridOut, 10000);
@@ -478,102 +508,120 @@ <h3>Global Control</h3>
478508
function propsHtml(rows) {
479509
return rows.map(([l,v]) => `<div class="prop"><div class="prop-label">${l}</div><div class="prop-value">${v}</div></div>`).join('');
480510
}
511+
function prop(label, reg, opts={}) {
512+
const suffix = opts.suffix || '';
513+
const intAttr = opts.int ? 'data-int="1"' : '';
514+
const digitsAttr = opts.digits ? `data-digits="${opts.digits}"` : '';
515+
const suffixAttr = suffix ? `data-suffix="${suffix}"` : '';
516+
return `<div class="prop"><div class="prop-label">${label}</div><div class="prop-value" data-reg="${reg}" ${intAttr} ${digitsAttr} ${suffixAttr}>${suffix || '--'}</div></div>`;
517+
}
481518
function detailInverter(r) {
482-
const out = [
483-
['AC Voltage (R/S/T)', `${fmt(val(r,'ac_output_voltage_r'))} / ${fmt(val(r,'ac_output_voltage_s'))} / ${fmt(val(r,'ac_output_voltage_t'))} V`],
484-
['AC Power (R/S/T)', `${fmtInt(val(r,'ac_output_power_r'))} / ${fmtInt(val(r,'ac_output_power_s'))} / ${fmtInt(val(r,'ac_output_power_t'))} W`],
485-
['EPS Power', `${fmtInt(val(r,'eps_power'))} W`],
486-
['EPS Frequency', `${fmt(val(r,'eps_frequency'),2)} Hz`],
487-
['State', STATE_MAP[val(r,'state',0)] || val(r,'state',0)],
488-
['Fault Code', fmtInt(val(r,'fault_code',0))],
489-
['Warning Code', fmtInt(val(r,'warning_code',0))],
490-
['Runtime', fmtInt(val(r,'runtime')) + ' min']
491-
];
492-
const temps = [
493-
['Inverter', `${fmt(val(r,'temp_inverter'))} °C`],
494-
['Battery', `${fmt(val(r,'temp_battery'))} °C`],
495-
['Radiator 1', `${fmt(val(r,'temp_radiator_1'))} °C`],
496-
['Radiator 2', `${fmt(val(r,'temp_radiator_2'))} °C`]
497-
];
498-
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Output</h3><div class="props">${propsHtml(out)}</div></div></div><div class="card"><div class="card-section"><h3>Temperatures</h3><div class="props">${propsHtml(temps)}</div></div></div></div>`;
519+
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Output</h3><div class="props">
520+
${prop('AC Voltage R','ac_output_voltage_r',{suffix:' V'})}
521+
${prop('AC Voltage S','ac_output_voltage_s',{suffix:' V'})}
522+
${prop('AC Voltage T','ac_output_voltage_t',{suffix:' V'})}
523+
${prop('AC Power R','ac_output_power_r',{suffix:' W', int:1})}
524+
${prop('AC Power S','ac_output_power_s',{suffix:' W', int:1})}
525+
${prop('AC Power T','ac_output_power_t',{suffix:' W', int:1})}
526+
${prop('EPS Power','eps_power',{suffix:' W', int:1})}
527+
${prop('EPS Frequency','eps_frequency',{suffix:' Hz', digits:2})}
528+
${prop('Fault Code','fault_code',{int:1})}
529+
${prop('Warning Code','warning_code',{int:1})}
530+
${prop('Runtime','runtime',{suffix:' min', int:1})}
531+
</div></div></div><div class="card"><div class="card-section"><h3>Temperatures</h3><div class="props">
532+
${prop('Inverter','temp_inverter',{suffix:' °C'})}
533+
${prop('Battery','temp_battery',{suffix:' °C'})}
534+
${prop('Radiator 1','temp_radiator_1',{suffix:' °C'})}
535+
${prop('Radiator 2','temp_radiator_2',{suffix:' °C'})}
536+
</div></div></div></div>`;
499537
}
500538
function detailBattery(r) {
501-
const props = [
502-
['SOC', `${fmtInt(val(r,'soc'))} %`],
503-
['SOH', `${fmtInt(val(r,'soh'))} %`],
504-
['Voltage', `${fmt(val(r,'battery_voltage'))} V`],
505-
['Current', `${fmt(val(r,'battery_current'))} A`],
506-
['Power', `${fmtInt(val(r,'battery_power'))} W`],
507-
['Capacity', `${fmt(val(r,'battery_capacity'))} Ah`],
508-
['Cycles', fmtInt(val(r,'cycle_count'))],
509-
['Temp', `${fmt(val(r,'temp_battery'))} °C`]
510-
];
511539
let cells = '';
512-
for (let i=1; i<=16; i++) {
513-
const v = val(r, 'cell_'+i+'_voltage');
514-
if (v === undefined) continue;
515-
cells += `<div class="prop"><div class="prop-label">Cell ${i}</div><div class="prop-value">${fmt(v,3)} V</div></div>`;
516-
}
517-
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Battery</h3><div class="props">${propsHtml(props)}</div></div></div><div class="card"><div class="card-section"><h3>Cells</h3><div class="props">${cells || '<div class="prop"><div class="prop-label">No cell data</div></div>'}</div></div></div></div>`;
540+
for (let i=1; i<=16; i++) cells += prop(`Cell ${i}`,`cell_${i}_voltage`,{suffix:' V', digits:3});
541+
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Battery</h3><div class="props">
542+
${prop('SOC','soc',{suffix:' %', int:1})}
543+
${prop('SOH','soh',{suffix:' %', int:1})}
544+
${prop('Voltage','battery_voltage',{suffix:' V'})}
545+
${prop('Current','battery_current',{suffix:' A'})}
546+
${prop('Power','battery_power',{suffix:' W', int:1})}
547+
${prop('Capacity','battery_capacity',{suffix:' Ah'})}
548+
${prop('Cycles','cycle_count',{int:1})}
549+
${prop('Temp','temp_battery',{suffix:' °C'})}
550+
</div></div></div><div class="card"><div class="card-section"><h3>Cells</h3><div class="props">${cells}</div></div></div></div>`;
518551
}
519552
function detailGrid(r) {
520-
const props = [
521-
['Voltage R/S/T', `${fmt(val(r,'grid_voltage_r'))} / ${fmt(val(r,'grid_voltage_s'))} / ${fmt(val(r,'grid_voltage_t'))} V`],
522-
['Frequency', `${fmt(val(r,'grid_frequency'),2)} Hz`],
523-
['Import Power', `${fmtInt(val(r,'grid_import_power'))} W`],
524-
['Export Power', `${fmtInt(val(r,'grid_export_power'))} W`],
525-
['Import Today', `${fmt(val(r,'grid_energy_in_today'),2)} kWh`],
526-
['Export Today', `${fmt(val(r,'grid_energy_out_today'),2)} kWh`]
527-
];
528-
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Grid</h3><div class="props">${propsHtml(props)}</div></div></div></div>`;
553+
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Grid</h3><div class="props">
554+
${prop('Voltage R','grid_voltage_r',{suffix:' V'})}
555+
${prop('Voltage S','grid_voltage_s',{suffix:' V'})}
556+
${prop('Voltage T','grid_voltage_t',{suffix:' V'})}
557+
${prop('Frequency','grid_frequency',{suffix:' Hz', digits:2})}
558+
${prop('Import Power','grid_import_power',{suffix:' W', int:1})}
559+
${prop('Export Power','grid_export_power',{suffix:' W', int:1})}
560+
${prop('Import Today','grid_energy_in_today',{suffix:' kWh', digits:2})}
561+
${prop('Export Today','grid_energy_out_today',{suffix:' kWh', digits:2})}
562+
</div></div></div></div>`;
529563
}
530564
function detailSolar(r) {
531-
const pv1 = val(r,'pv1_power') || 0;
532-
const pv2 = val(r,'pv2_power') || 0;
533-
const props = [
534-
['Total PV Power', `${fmtInt(pv1+pv2)} W`],
535-
['PV1 Power', `${fmtInt(pv1)} W`],
536-
['PV2 Power', `${fmtInt(pv2)} W`],
537-
['PV1 Voltage', `${fmt(val(r,'pv1_voltage'))} V`],
538-
['PV2 Voltage', `${fmt(val(r,'pv2_voltage'))} V`],
539-
['PV Energy Today', `${fmt(val(r,'pv_energy_today'),2)} kWh`]
540-
];
541-
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Solar</h3><div class="props">${propsHtml(props)}</div></div></div></div>`;
565+
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Solar</h3><div class="props">
566+
${prop('Total PV Power','pv_total_power',{suffix:' W', int:1})}
567+
${prop('PV1 Power','pv1_power',{suffix:' W', int:1})}
568+
${prop('PV2 Power','pv2_power',{suffix:' W', int:1})}
569+
${prop('PV1 Voltage','pv1_voltage',{suffix:' V'})}
570+
${prop('PV2 Voltage','pv2_voltage',{suffix:' V'})}
571+
${prop('PV Energy Today','pv_energy_today',{suffix:' kWh', digits:2})}
572+
</div></div></div></div>`;
542573
}
543574
function detailLoad(r) {
544-
const pv1 = val(r,'pv1_power') || 0;
545-
const pv2 = val(r,'pv2_power') || 0;
546-
const gridIn = val(r,'grid_import_power') || 0;
547-
const gridOut = val(r,'grid_export_power') || 0;
548-
const chargeP = val(r,'charge_power') || 0;
549-
const dischargeP = val(r,'discharge_power') || 0;
550-
const load = val(r,'load_power') || val(r,'total_load_power') || (pv1+pv2 + gridIn - gridOut + (dischargeP - chargeP));
551-
const props = [
552-
['Total Load', `${fmtInt(load)} W`],
553-
['EPS Power', `${fmtInt(val(r,'eps_power'))} W`],
554-
['Load Energy Today', `${fmt(val(r,'load_energy_today'),2)} kWh`],
555-
['EPS Energy Today', `${fmt(val(r,'eps_energy_today'),2)} kWh`]
556-
];
557-
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Load</h3><div class="props">${propsHtml(props)}</div></div></div></div>`;
575+
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Load</h3><div class="props">
576+
${prop('Total Load','load_power',{suffix:' W', int:1})}
577+
${prop('EPS Power','eps_power',{suffix:' W', int:1})}
578+
${prop('Load Energy Today','load_energy_today',{suffix:' kWh', digits:2})}
579+
${prop('EPS Energy Today','eps_energy_today',{suffix:' kWh', digits:2})}
580+
</div></div></div></div>`;
558581
}
559582
function detailEnvironment(r) {
560-
const props = [
561-
['Inverter Temp', `${fmt(val(r,'temp_inverter'))} °C`],
562-
['Battery Temp', `${fmt(val(r,'temp_battery'))} °C`],
563-
['Radiator 1', `${fmt(val(r,'temp_radiator_1'))} °C`],
564-
['Radiator 2', `${fmt(val(r,'temp_radiator_2'))} °C`],
565-
['Outside Temp', `${fmt(val(r,'temp_outside'))} °C`]
566-
];
567-
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Environment</h3><div class="props">${propsHtml(props)}</div></div></div></div>`;
583+
return `<div class="detail-grid"><div class="card"><div class="card-section"><h3>Environment</h3><div class="props">
584+
${prop('Inverter Temp','temp_inverter',{suffix:' °C'})}
585+
${prop('Battery Temp','temp_battery',{suffix:' °C'})}
586+
${prop('Radiator 1','temp_radiator_1',{suffix:' °C'})}
587+
${prop('Radiator 2','temp_radiator_2',{suffix:' °C'})}
588+
${prop('Outside Temp','temp_outside',{suffix:' °C'})}
589+
</div></div></div></div>`;
568590
}
569591

570592
function showDetail(name) {
571593
const titles = {inverter:'Inverter', battery:'Battery', grid:'Grid', solar:'Solar', load:'Load', environment:'Environment'};
572594
document.getElementById('detail-title').textContent = titles[name] || name;
573-
const fn = {inverter:detailInverter, battery:detailBattery, grid:detailGrid, solar:detailSolar, load:detailLoad, environment:detailEnvironment}[name];
574-
document.getElementById('detail-content').innerHTML = fn ? fn(liveData) : '<p>No detail available.</p>';
595+
currentDetail = name;
596+
detailRendered = false;
597+
renderDetailHTML();
575598
showPage('detail');
576599
}
600+
function renderDetailHTML() {
601+
if (!currentDetail) return;
602+
const fn = {inverter:detailInverter, battery:detailBattery, grid:detailGrid, solar:detailSolar, load:detailLoad, environment:detailEnvironment}[currentDetail];
603+
const content = document.getElementById('detail-content');
604+
if (!detailRendered) {
605+
content.innerHTML = fn ? fn(liveData) : '<p>No detail available.</p>';
606+
detailRendered = true;
607+
} else {
608+
// Only update text values to avoid image/text flicker; keep structure
609+
content.querySelectorAll('[data-reg]').forEach(el => {
610+
const v = val(liveData, el.dataset.reg, null);
611+
if (v !== null && v !== undefined) {
612+
const fmtFn = el.dataset.int ? fmtInt : (el.dataset.digits ? (n=>fmt(n, Number(el.dataset.digits))) : fmt);
613+
let text = fmtFn(v);
614+
if (text === null || text === undefined) text = '--';
615+
if (el.dataset.suffix) text += el.dataset.suffix;
616+
el.textContent = text;
617+
}
618+
});
619+
}
620+
}
621+
function updateDetail(r) {
622+
liveData = r;
623+
renderDetailHTML();
624+
}
577625

578626
function createLineChart(id, datasets, opts={}) {
579627
const ctx = document.getElementById(id);
@@ -805,10 +853,8 @@ <h3>Global Control</h3>
805853
try {
806854
const r = await fetchJson(API + '/api/summary');
807855
renderMain(r);
808-
if (document.getElementById('page-detail').classList.contains('active')) {
809-
const name = document.getElementById('detail-title').textContent.toLowerCase();
810-
const key = name === 'environment' ? 'environment' : (name === 'solar' ? 'solar' : (['inverter','battery','grid','load'].includes(name)?name:'inverter'));
811-
showDetail(key);
856+
if (currentDetail) {
857+
updateDetail(r);
812858
}
813859
} catch (e) { console.error(e); }
814860
}
@@ -876,7 +922,7 @@ <h3>Global Control</h3>
876922
refresh();
877923
loadMainCharts();
878924
connectWS();
879-
setInterval(refresh, 5000);
925+
setInterval(refresh, 15000);
880926
});
881927
</script>
882928
</body>

0 commit comments

Comments
 (0)