Skip to content

Commit 9f358ee

Browse files
committed
Add Tank Alarm Viewer 112025 for Arduino Opta with Blues Notecard integration
- Implemented a read-only kiosk application to display tank data from a server dashboard. - Configured to fetch summarized notefile every 6 hours starting at 6 AM. - Utilized ArduinoJson for JSON handling and Ethernet for network communication. - Included HTML dashboard for user interface with real-time updates on tank status. - Defined structures for tank records and implemented functions for data fetching and display. - Added support for manual refresh of tank data and site filtering in the UI. - Integrated watchdog functionality for enhanced reliability on supported platforms.
1 parent f6e816d commit 9f358ee

File tree

4 files changed

+1070
-18
lines changed

4 files changed

+1070
-18
lines changed

TankAlarm-112025-Client-BluesOpta/FLEET_IMPLEMENTATION_SUMMARY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,7 @@ SMS recipients now reside exclusively in the server configuration. Client-side `
182182
"dailyEmail": "[email protected]",
183183
"dailyHour": 6,
184184
"dailyMinute": 0,
185-
"webRefreshSeconds": 15,
185+
"webRefreshSeconds": 21600,
186186
"useStaticIp": true
187187
}
188188
```

TankAlarm-112025-Server-BluesOpta/FLEET_SETUP.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ The server configuration file (`/server_config.json` on LittleFS) includes:
8787
"dailyEmail": "[email protected]",
8888
"dailyHour": 6,
8989
"dailyMinute": 0,
90-
"webRefreshSeconds": 15,
90+
"webRefreshSeconds": 21600,
9191
"useStaticIp": true
9292
}
9393
```
@@ -143,6 +143,7 @@ The server configuration file (`/server_config.json` on LittleFS) includes:
143143
| Client → Server (alarm) | `fleet.tankalarm-server:alarm.qi` | Alarm state changes |
144144
| Client → Server (daily) | `fleet.tankalarm-server:daily.qi` | Daily summary reports |
145145
| Server → Client (config) | `device:<client-uid>:config.qi` | Configuration updates |
146+
| Server → Viewer (summary) | `viewer_summary.qo` (route to viewer fleet `viewer_summary.qi`) | 6-hour fleet snapshot |
146147
| Server → SMS Gateway | `sms.qo` | SMS alert requests |
147148
| Server → Email Gateway | `email.qo` | Email report requests |
148149

TankAlarm-112025-Server-BluesOpta/TankAlarm-112025-Server-BluesOpta.ino

Lines changed: 192 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,18 @@
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+
115127
static const size_t TANK_JSON_CAPACITY = JSON_ARRAY_SIZE(MAX_TANK_RECORDS) + (MAX_TANK_RECORDS * JSON_OBJECT_SIZE(10)) + 512;
116128
static 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};
176188
static double gLastSyncedEpoch = 0.0;
177189
static unsigned long gLastSyncMillis = 0;
178190
static double gNextDailyEmailEpoch = 0.0;
191+
static double gNextViewerSummaryEpoch = 0.0;
192+
static double gLastViewerSummaryEpoch = 0.0;
179193

180194
static 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();
14501520
static void ensureTimeSync();
14511521
static double currentEpoch();
14521522
static void scheduleNextDailyEmail();
1523+
static void scheduleNextViewerSummary();
14531524
static void initializeEthernet();
14541525
static void handleWebRequests();
14551526
static bool readHttpRequest(EthernetClient &client, String &method, String &path, String &body, size_t &contentLength);
@@ -1461,11 +1532,35 @@ static void sendTankJson(EthernetClient &client);
14611532
static void sendClientDataJson(EthernetClient &client);
14621533
static void handleConfigPost(EthernetClient &client, const String &body);
14631534
static void handlePinPost(EthernetClient &client, const String &body);
1535+
static void handleRefreshPost(EthernetClient &client, const String &body);
14641536
enum 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+
}
14691564
static ConfigDispatchStatus dispatchClientConfig(const char *clientUid, JsonVariantConst cfgObj);
14701565
static void pollNotecard();
14711566
static void processNotefile(const char *fileName, void (*handler)(JsonDocument &, double));
@@ -1480,6 +1575,8 @@ static void saveClientConfigSnapshots();
14801575
static void cacheClientConfigFromBuffer(const char *clientUid, const char *buffer);
14811576
static ClientConfigSnapshot *findClientConfigSnapshot(const char *clientUid);
14821577
static bool checkSmsRateLimit(TankRecord *rec);
1578+
static void publishViewerSummary();
1579+
static double computeNextAlignedEpoch(double epoch, uint8_t baseHour, uint32_t intervalSeconds);
14831580

14841581
void 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+
17881911
static 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+
25542730
static ClientConfigSnapshot *findClientConfigSnapshot(const char *clientUid) {
25552731
if (!clientUid) {
25562732
return nullptr;

0 commit comments

Comments
 (0)