112112#define MAX_CLIENT_CONFIG_SNAPSHOTS 20
113113#endif
114114
115+ #ifndef VIEWER_SUMMARY_FILE
116+ #define VIEWER_SUMMARY_FILE " viewer_summary.qo"
117+ #endif
118+
119+ #ifndef VIEWER_SUMMARY_INTERVAL_SECONDS
120+ #define VIEWER_SUMMARY_INTERVAL_SECONDS 21600UL // 6 hours
121+ #endif
122+
123+ #ifndef VIEWER_SUMMARY_BASE_HOUR
124+ #define VIEWER_SUMMARY_BASE_HOUR 6
125+ #endif
126+
115127static const size_t TANK_JSON_CAPACITY = JSON_ARRAY_SIZE(MAX_TANK_RECORDS) + (MAX_TANK_RECORDS * JSON_OBJECT_SIZE (10 )) + 512;
116128static const size_t CLIENT_JSON_CAPACITY = 24576 ;
117129
@@ -130,7 +142,7 @@ struct ServerConfig {
130142 char configPin[8 ];
131143 uint8_t dailyHour;
132144 uint8_t dailyMinute;
133- uint8_t webRefreshSeconds;
145+ uint16_t webRefreshSeconds;
134146 bool useStaticIp;
135147 bool smsOnHigh;
136148 bool smsOnLow;
@@ -176,6 +188,8 @@ static char gServerUid[48] = {0};
176188static double gLastSyncedEpoch = 0.0 ;
177189static unsigned long gLastSyncMillis = 0 ;
178190static double gNextDailyEmailEpoch = 0.0 ;
191+ static double gNextViewerSummaryEpoch = 0.0 ;
192+ static double gLastViewerSummaryEpoch = 0.0 ;
179193
180194static unsigned long gLastPollMillis = 0 ;
181195
@@ -660,6 +674,12 @@ static const char DASHBOARD_HTML[] PROGMEM = R"HTML(
660674 margin: 10px 0 20px;
661675 flex-wrap: wrap;
662676 }
677+ .refresh-actions {
678+ display: flex;
679+ gap: 10px;
680+ margin: 10px 0 20px;
681+ flex-wrap: wrap;
682+ }
663683 .toggle-group {
664684 display: grid;
665685 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
@@ -753,6 +773,10 @@ static const char DASHBOARD_HTML[] PROGMEM = R"HTML(
753773 <select id="clientSelect"></select>
754774 </label>
755775 <div id="clientDetails" class="details">Select a client to review configuration.</div>
776+ <div class="refresh-actions">
777+ <button type="button" id="refreshSelectedBtn">Refresh Selected Site</button>
778+ <button type="button" class="secondary" id="refreshAllBtn">Refresh All Sites</button>
779+ </div>
756780 <div class="pin-actions">
757781 <button type="button" class="secondary" id="changePinBtn" data-pin-control="true">Change PIN</button>
758782 <button type="button" class="secondary" id="lockPinBtn" data-pin-control="true">Lock Console</button>
@@ -877,7 +901,9 @@ static const char DASHBOARD_HTML[] PROGMEM = R"HTML(
877901 addTank: document.getElementById('addTank'),
878902 form: document.getElementById('configForm'),
879903 changePinBtn: document.getElementById('changePinBtn'),
880- lockPinBtn: document.getElementById('lockPinBtn')
904+ lockPinBtn: document.getElementById('lockPinBtn'),
905+ refreshSelectedBtn: document.getElementById('refreshSelectedBtn'),
906+ refreshAllBtn: document.getElementById('refreshAllBtn')
881907 };
882908
883909 const pinEls = {
@@ -1378,23 +1404,22 @@ static const char DASHBOARD_HTML[] PROGMEM = R"HTML(
13781404 throw new Error(text || 'Server rejected configuration');
13791405 }
13801406 showToast('Configuration queued for delivery');
1381- await refreshData();
1407+ await refreshData(state.selected );
13821408 } catch (err) {
13831409 showToast(err.message || 'Failed to send config', true);
13841410 }
13851411 }
13861412
1387- async function refreshData() {
1388- const res = await fetch('/api/clients');
1389- if (!res.ok) {
1390- throw new Error('Failed to fetch server data');
1391- }
1392- state.data = await res.json();
1393- const serverInfo = (state.data && state.data.server) ? state.data.server : {};
1394- els.serverName.textContent = serverInfo.name || 'Tank Alarm Server';
1395- syncServerSettings(serverInfo);
1413+ function applyServerData(data, preferredUid) {
1414+ state.data = data;
1415+ const serverInfo = (state.data && state.data.server) ? state.data.server : {};
1416+ els.serverName.textContent = serverInfo.name || 'Tank Alarm Server';
1417+ syncServerSettings(serverInfo);
13961418 els.serverUid.textContent = state.data.serverUid || '--';
13971419 els.nextEmail.textContent = formatEpoch(state.data.nextDailyEmailEpoch);
1420+ if (preferredUid) {
1421+ state.selected = preferredUid;
1422+ }
13981423 renderTelemetry();
13991424 populateClientSelect();
14001425 if (state.selected) {
@@ -1405,6 +1430,40 @@ static const char DASHBOARD_HTML[] PROGMEM = R"HTML(
14051430 updatePinLock();
14061431 }
14071432
1433+ async function refreshData(preferredUid) {
1434+ try {
1435+ const query = preferredUid ? `?client=${encodeURIComponent(preferredUid)}` : '';
1436+ const res = await fetch(`/api/clients${query}`);
1437+ if (!res.ok) {
1438+ throw new Error('Failed to fetch server data');
1439+ }
1440+ const data = await res.json();
1441+ applyServerData(data, preferredUid || state.selected);
1442+ } catch (err) {
1443+ showToast(err.message || 'Initialization failed', true);
1444+ }
1445+ }
1446+
1447+ async function triggerManualRefresh(targetUid) {
1448+ const payload = targetUid ? { client: targetUid } : {};
1449+ try {
1450+ const res = await fetch('/api/refresh', {
1451+ method: 'POST',
1452+ headers: { 'Content-Type': 'application/json' },
1453+ body: JSON.stringify(payload)
1454+ });
1455+ if (!res.ok) {
1456+ const text = await res.text();
1457+ throw new Error(text || 'Refresh failed');
1458+ }
1459+ const data = await res.json();
1460+ applyServerData(data, targetUid || state.selected);
1461+ showToast(targetUid ? 'Selected site updated' : 'All sites updated');
1462+ } catch (err) {
1463+ showToast(err.message || 'Refresh failed', true);
1464+ }
1465+ }
1466+
14081467 pinEls.form.addEventListener('submit', handlePinSubmit);
14091468 pinEls.cancel.addEventListener('click', () => {
14101469 hidePinModal();
@@ -1432,8 +1491,19 @@ static const char DASHBOARD_HTML[] PROGMEM = R"HTML(
14321491 loadConfigIntoForm(event.target.value);
14331492 });
14341493 els.form.addEventListener('submit', submitConfig);
1494+ els.refreshSelectedBtn.addEventListener('click', () => {
1495+ const target = state.selected || (els.clientSelect.value || '');
1496+ if (!target) {
1497+ showToast('Select a client first.', true);
1498+ return;
1499+ }
1500+ triggerManualRefresh(target);
1501+ });
1502+ els.refreshAllBtn.addEventListener('click', () => {
1503+ triggerManualRefresh(null);
1504+ });
14351505
1436- refreshData().catch(err => showToast(err.message || 'Initialization failed', true)) ;
1506+ refreshData();
14371507 })();
14381508 </script>
14391509</body>
@@ -1450,6 +1520,7 @@ static void initializeNotecard();
14501520static void ensureTimeSync ();
14511521static double currentEpoch ();
14521522static void scheduleNextDailyEmail ();
1523+ static void scheduleNextViewerSummary ();
14531524static void initializeEthernet ();
14541525static void handleWebRequests ();
14551526static bool readHttpRequest (EthernetClient &client, String &method, String &path, String &body, size_t &contentLength);
@@ -1461,11 +1532,35 @@ static void sendTankJson(EthernetClient &client);
14611532static void sendClientDataJson (EthernetClient &client);
14621533static void handleConfigPost (EthernetClient &client, const String &body);
14631534static void handlePinPost (EthernetClient &client, const String &body);
1535+ static void handleRefreshPost (EthernetClient &client, const String &body);
14641536enum class ConfigDispatchStatus : uint8_t {
14651537 Ok = 0 ,
14661538 PayloadTooLarge,
14671539 NotecardFailure
14681540};
1541+
1542+ static void handleRefreshPost (EthernetClient &client, const String &body) {
1543+ char clientUid[64 ] = {0 };
1544+ if (body.length () > 0 ) {
1545+ DynamicJsonDocument doc (128 );
1546+ if (deserializeJson (doc, body) == DeserializationError::Ok) {
1547+ const char *uid = doc[" client" ] | " " ;
1548+ if (uid && *uid) {
1549+ strlcpy (clientUid, uid, sizeof (clientUid));
1550+ }
1551+ }
1552+ }
1553+
1554+ if (clientUid[0 ]) {
1555+ Serial.print (F (" Manual refresh requested for client " ));
1556+ Serial.println (clientUid);
1557+ } else {
1558+ Serial.println (F (" Manual refresh requested for all clients" ));
1559+ }
1560+
1561+ pollNotecard ();
1562+ sendClientDataJson (client);
1563+ }
14691564static ConfigDispatchStatus dispatchClientConfig (const char *clientUid, JsonVariantConst cfgObj);
14701565static void pollNotecard ();
14711566static void processNotefile (const char *fileName, void (*handler)(JsonDocument &, double ));
@@ -1480,6 +1575,8 @@ static void saveClientConfigSnapshots();
14801575static void cacheClientConfigFromBuffer (const char *clientUid, const char *buffer);
14811576static ClientConfigSnapshot *findClientConfigSnapshot (const char *clientUid);
14821577static bool checkSmsRateLimit (TankRecord *rec);
1578+ static void publishViewerSummary ();
1579+ static double computeNextAlignedEpoch (double epoch, uint8_t baseHour, uint32_t intervalSeconds);
14831580
14841581void setup () {
14851582 Serial.begin (115200 );
@@ -1500,6 +1597,7 @@ void setup() {
15001597 initializeNotecard ();
15011598 ensureTimeSync ();
15021599 scheduleNextDailyEmail ();
1600+ scheduleNextViewerSummary ();
15031601
15041602 initializeEthernet ();
15051603 gWebServer .begin ();
@@ -1538,6 +1636,11 @@ void loop() {
15381636 scheduleNextDailyEmail ();
15391637 }
15401638
1639+ if (gNextViewerSummaryEpoch > 0.0 && currentEpoch () >= gNextViewerSummaryEpoch ) {
1640+ publishViewerSummary ();
1641+ scheduleNextViewerSummary ();
1642+ }
1643+
15411644 if (gConfigDirty ) {
15421645 if (saveConfig (gConfig )) {
15431646 gConfigDirty = false ;
@@ -1574,7 +1677,7 @@ static void createDefaultConfig(ServerConfig &cfg) {
15741677 cfg.configPin [0 ] = ' \0 ' ;
15751678 cfg.dailyHour = DAILY_EMAIL_HOUR_DEFAULT;
15761679 cfg.dailyMinute = DAILY_EMAIL_MINUTE_DEFAULT;
1577- cfg.webRefreshSeconds = 15 ;
1680+ cfg.webRefreshSeconds = 21600 ;
15781681 cfg.useStaticIp = true ;
15791682 cfg.smsOnHigh = true ;
15801683 cfg.smsOnLow = true ;
@@ -1613,7 +1716,11 @@ static bool loadConfig(ServerConfig &cfg) {
16131716 }
16141717 cfg.dailyHour = doc[" dailyHour" ].is <uint8_t >() ? doc[" dailyHour" ].as <uint8_t >() : DAILY_EMAIL_HOUR_DEFAULT;
16151718 cfg.dailyMinute = doc[" dailyMinute" ].is <uint8_t >() ? doc[" dailyMinute" ].as <uint8_t >() : DAILY_EMAIL_MINUTE_DEFAULT;
1616- cfg.webRefreshSeconds = doc[" webRefreshSeconds" ].is <uint8_t >() ? doc[" webRefreshSeconds" ].as <uint8_t >() : 15 ;
1719+ if (doc[" webRefreshSeconds" ].is <uint16_t >() || doc[" webRefreshSeconds" ].is <uint32_t >()) {
1720+ cfg.webRefreshSeconds = doc[" webRefreshSeconds" ].as <uint16_t >();
1721+ } else {
1722+ cfg.webRefreshSeconds = 21600 ;
1723+ }
16171724 cfg.useStaticIp = doc[" useStaticIp" ].is <bool >() ? doc[" useStaticIp" ].as <bool >() : true ;
16181725 cfg.smsOnHigh = doc[" smsOnHigh" ].is <bool >() ? doc[" smsOnHigh" ].as <bool >() : true ;
16191726 cfg.smsOnLow = doc[" smsOnLow" ].is <bool >() ? doc[" smsOnLow" ].as <bool >() : true ;
@@ -1785,6 +1892,22 @@ static void scheduleNextDailyEmail() {
17851892 gNextDailyEmailEpoch = scheduled;
17861893}
17871894
1895+ static double computeNextAlignedEpoch (double epoch, uint8_t baseHour, uint32_t intervalSeconds) {
1896+ if (epoch <= 0.0 || intervalSeconds == 0 ) {
1897+ return 0.0 ;
1898+ }
1899+ double aligned = floor (epoch / 86400.0 ) * 86400.0 + (double )baseHour * 3600.0 ;
1900+ while (aligned <= epoch) {
1901+ aligned += (double )intervalSeconds;
1902+ }
1903+ return aligned;
1904+ }
1905+
1906+ static void scheduleNextViewerSummary () {
1907+ double epoch = currentEpoch ();
1908+ gNextViewerSummaryEpoch = computeNextAlignedEpoch (epoch, VIEWER_SUMMARY_BASE_HOUR, VIEWER_SUMMARY_INTERVAL_SECONDS);
1909+ }
1910+
17881911static void initializeEthernet () {
17891912 Serial.print (F (" Initializing Ethernet..." ));
17901913 int status;
@@ -1832,6 +1955,8 @@ static void handleWebRequests() {
18321955 handleConfigPost (client, body);
18331956 } else if (method == " POST" && path == " /api/pin" ) {
18341957 handlePinPost (client, body);
1958+ } else if (method == " POST" && path == " /api/refresh" ) {
1959+ handleRefreshPost (client, body);
18351960 } else {
18361961 respondStatus (client, 404 , F (" Not Found" ));
18371962 }
@@ -2142,7 +2267,7 @@ static void handleConfigPost(EthernetClient &client, const String &body) {
21422267 gConfig .dailyMinute = serverObj[" dailyMinute" ].as <uint8_t >();
21432268 }
21442269 if (serverObj.containsKey (" webRefreshSeconds" )) {
2145- gConfig .webRefreshSeconds = serverObj[" webRefreshSeconds" ].as <uint8_t >();
2270+ gConfig .webRefreshSeconds = serverObj[" webRefreshSeconds" ].as <uint16_t >();
21462271 }
21472272 if (serverObj.containsKey (" smsOnHigh" )) {
21482273 gConfig .smsOnHigh = serverObj[" smsOnHigh" ].as <bool >();
@@ -2551,6 +2676,57 @@ static void sendDailyEmail() {
25512676 Serial.println (F (" Daily email queued" ));
25522677}
25532678
2679+ static void publishViewerSummary () {
2680+ DynamicJsonDocument doc (TANK_JSON_CAPACITY + 1024 );
2681+ doc[" serverName" ] = gConfig .serverName ;
2682+ doc[" serverUid" ] = gServerUid ;
2683+ double now = currentEpoch ();
2684+ doc[" generatedEpoch" ] = now;
2685+ doc[" refreshSeconds" ] = VIEWER_SUMMARY_INTERVAL_SECONDS;
2686+ doc[" baseHour" ] = VIEWER_SUMMARY_BASE_HOUR;
2687+ JsonArray arr = doc.createNestedArray (" tanks" );
2688+ for (uint8_t i = 0 ; i < gTankRecordCount ; ++i) {
2689+ JsonObject obj = arr.createNestedObject ();
2690+ obj[" client" ] = gTankRecords [i].clientUid ;
2691+ obj[" site" ] = gTankRecords [i].site ;
2692+ obj[" label" ] = gTankRecords [i].label ;
2693+ obj[" tank" ] = gTankRecords [i].tankNumber ;
2694+ obj[" heightInches" ] = gTankRecords [i].heightInches ;
2695+ obj[" levelInches" ] = gTankRecords [i].levelInches ;
2696+ obj[" percent" ] = gTankRecords [i].percent ;
2697+ obj[" alarm" ] = gTankRecords [i].alarmActive ;
2698+ obj[" alarmType" ] = gTankRecords [i].alarmType ;
2699+ obj[" lastUpdate" ] = gTankRecords [i].lastUpdateEpoch ;
2700+ }
2701+
2702+ String json;
2703+ if (serializeJson (doc, json) == 0 ) {
2704+ Serial.println (F (" Viewer summary serialization failed" ));
2705+ return ;
2706+ }
2707+
2708+ J *req = notecard.newRequest (" note.add" );
2709+ if (!req) {
2710+ return ;
2711+ }
2712+ JAddStringToObject (req, " file" , VIEWER_SUMMARY_FILE);
2713+ JAddBoolToObject (req, " sync" , true );
2714+ J *body = JParse (json.c_str ());
2715+ if (!body) {
2716+ notecard.deleteRequest (req);
2717+ Serial.println (F (" Viewer summary JSON parse failed" ));
2718+ return ;
2719+ }
2720+ JAddItemToObject (req, " body" , body);
2721+ bool queued = notecard.sendRequest (req);
2722+ if (queued) {
2723+ gLastViewerSummaryEpoch = now;
2724+ Serial.println (F (" Viewer summary queued" ));
2725+ } else {
2726+ Serial.println (F (" Viewer summary queue failed" ));
2727+ }
2728+ }
2729+
25542730static ClientConfigSnapshot *findClientConfigSnapshot (const char *clientUid) {
25552731 if (!clientUid) {
25562732 return nullptr ;
0 commit comments