Skip to content

Commit bd16e1f

Browse files
authored
Merge pull request #96 from SenaxInc/copilot/report-blues-vin-voltage
Report Blues VIN voltage in daily report
2 parents 24fe702 + d28d515 commit bd16e1f

File tree

3 files changed

+174
-8
lines changed

3 files changed

+174
-8
lines changed

TankAlarm-112025-Client-BluesOpta/TankAlarm-112025-Client-BluesOpta.ino

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ static void setRelayState(uint8_t relayNum, bool state);
277277
static void initializeRelays();
278278
static void triggerRemoteRelays(const char *targetClient, uint8_t relayMask, bool activate);
279279
static int getRelayPin(uint8_t relayIndex);
280+
static float readNotecardVinVoltage();
280281

281282
void setup() {
282283
Serial.begin(115200);
@@ -1368,6 +1369,9 @@ static void sendDailyReport() {
13681369
uint8_t part = 0;
13691370
bool queuedAny = false;
13701371

1372+
// Read VIN voltage once for the daily report
1373+
float vinVoltage = readNotecardVinVoltage();
1374+
13711375
while (tankCursor < eligibleCount) {
13721376
DynamicJsonDocument doc(1024);
13731377
doc["client"] = gDeviceUID;
@@ -1376,6 +1380,11 @@ static void sendDailyReport() {
13761380
doc["time"] = reportEpoch;
13771381
doc["part"] = static_cast<uint8_t>(part + 1);
13781382

1383+
// Include VIN voltage in the first part of the daily report
1384+
if (part == 0 && vinVoltage > 0.0f) {
1385+
doc["vinVoltage"] = vinVoltage;
1386+
}
1387+
13791388
JsonArray tanks = doc.createNestedArray("tanks");
13801389
bool addedTank = false;
13811390

@@ -1844,3 +1853,41 @@ static void triggerRemoteRelays(const char *targetClient, uint8_t relayMask, boo
18441853
}
18451854
}
18461855
}
1856+
1857+
// ============================================================================
1858+
// Notecard VIN Voltage Reading
1859+
// ============================================================================
1860+
1861+
static float readNotecardVinVoltage() {
1862+
J *req = notecard.newRequest("card.voltage");
1863+
if (!req) {
1864+
Serial.println(F("Failed to create card.voltage request"));
1865+
return -1.0f;
1866+
}
1867+
1868+
J *rsp = notecard.requestAndResponse(req);
1869+
if (!rsp) {
1870+
Serial.println(F("No response from card.voltage"));
1871+
return -1.0f;
1872+
}
1873+
1874+
// Check for error response
1875+
const char *err = JGetString(rsp, "err");
1876+
if (err && strlen(err) > 0) {
1877+
Serial.print(F("card.voltage error: "));
1878+
Serial.println(err);
1879+
notecard.deleteResponse(rsp);
1880+
return -1.0f;
1881+
}
1882+
1883+
double voltage = JGetNumber(rsp, "value");
1884+
notecard.deleteResponse(rsp);
1885+
1886+
if (voltage > 0.0) {
1887+
Serial.print(F("Notecard VIN voltage: "));
1888+
Serial.print(voltage);
1889+
Serial.println(F(" V"));
1890+
}
1891+
1892+
return (float)voltage;
1893+
}

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

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,20 @@ struct TankRecord {
182182
double smsAlertTimestamps[10]; // Track last 10 SMS alerts per tank
183183
};
184184

185+
// Per-client metadata (VIN voltage, etc.)
186+
#ifndef MAX_CLIENT_METADATA
187+
#define MAX_CLIENT_METADATA 20
188+
#endif
189+
190+
struct ClientMetadata {
191+
char clientUid[48];
192+
float vinVoltage; // Blues Notecard VIN voltage from daily report
193+
double vinVoltageEpoch; // When the VIN voltage was last updated
194+
};
195+
196+
static ClientMetadata gClientMetadata[MAX_CLIENT_METADATA];
197+
static uint8_t gClientMetadataCount = 0;
198+
185199
static ServerConfig gConfig;
186200
static bool gConfigDirty = false;
187201

@@ -1089,6 +1103,7 @@ static const char DASHBOARD_HTML[] PROGMEM = R"HTML(
10891103
<th>Site</th>
10901104
<th>Tank</th>
10911105
<th>Level</th>
1106+
<th>VIN Voltage</th>
10921107
<th>Status</th>
10931108
<th>Updated</th>
10941109
<th>Refresh</th>
@@ -1188,6 +1203,9 @@ static const char DASHBOARD_HTML[] PROGMEM = R"HTML(
11881203
return `${hours} h`;
11891204
}
11901205
1206+
// Flatten client/tank hierarchy into rows for display.
1207+
// VIN voltage is a per-client value (from Blues Notecard), so it's only
1208+
// shown on the first tank row for each client to avoid redundancy.
11911209
function flattenTanks(clients) {
11921210
const rows = [];
11931211
clients.forEach(client => {
@@ -1202,11 +1220,12 @@ static const char DASHBOARD_HTML[] PROGMEM = R"HTML(
12021220
percent: client.percent,
12031221
alarm: client.alarm,
12041222
alarmType: client.alarmType,
1205-
lastUpdate: client.lastUpdate
1223+
lastUpdate: client.lastUpdate,
1224+
vinVoltage: client.vinVoltage
12061225
});
12071226
return;
12081227
}
1209-
tanks.forEach(tank => {
1228+
tanks.forEach((tank, idx) => {
12101229
rows.push({
12111230
client: client.client,
12121231
site: client.site,
@@ -1216,20 +1235,28 @@ static const char DASHBOARD_HTML[] PROGMEM = R"HTML(
12161235
percent: tank.percent,
12171236
alarm: tank.alarm,
12181237
alarmType: tank.alarmType || client.alarmType,
1219-
lastUpdate: tank.lastUpdate
1238+
lastUpdate: tank.lastUpdate,
1239+
vinVoltage: idx === 0 ? client.vinVoltage : null // Only show VIN on first tank per client
12201240
});
12211241
});
12221242
});
12231243
return rows;
12241244
}
12251245
1246+
function formatVoltage(voltage) {
1247+
if (typeof voltage !== 'number' || !isFinite(voltage) || voltage <= 0) {
1248+
return '--';
1249+
}
1250+
return voltage.toFixed(2) + ' V';
1251+
}
1252+
12261253
function renderTankRows() {
12271254
const tbody = els.tankBody;
12281255
tbody.innerHTML = '';
12291256
const rows = state.tanks;
12301257
if (!rows.length) {
12311258
const tr = document.createElement('tr');
1232-
tr.innerHTML = '<td colspan="7">No telemetry available</td>';
1259+
tr.innerHTML = '<td colspan="8">No telemetry available</td>';
12331260
tbody.appendChild(tr);
12341261
return;
12351262
}
@@ -1241,6 +1268,7 @@ static const char DASHBOARD_HTML[] PROGMEM = R"HTML(
12411268
<td>${row.site || '--'}</td>
12421269
<td>${row.label || 'Tank'} #${row.tank || '?'}</td>
12431270
<td>${formatLevel(row.levelInches)}</td>
1271+
<td>${formatVoltage(row.vinVoltage)}</td>
12441272
<td>${statusBadge(row)}</td>
12451273
<td>${formatEpoch(row.lastUpdate)}</td>
12461274
<td>${refreshButton(row)}</td>`;
@@ -2513,6 +2541,8 @@ static void loadClientConfigSnapshots();
25132541
static void saveClientConfigSnapshots();
25142542
static void cacheClientConfigFromBuffer(const char *clientUid, const char *buffer);
25152543
static ClientConfigSnapshot *findClientConfigSnapshot(const char *clientUid);
2544+
static ClientMetadata *findClientMetadata(const char *clientUid);
2545+
static ClientMetadata *findOrCreateClientMetadata(const char *clientUid);
25162546
static bool checkSmsRateLimit(TankRecord *rec);
25172547
static void publishViewerSummary();
25182548
static double computeNextAlignedEpoch(double epoch, uint8_t baseHour, uint32_t intervalSeconds);
@@ -3210,6 +3240,13 @@ static void sendClientDataJson(EthernetClient &client) {
32103240
clientObj["site"] = rec.site;
32113241
clientObj["alarm"] = false;
32123242
clientObj["lastUpdate"] = 0.0;
3243+
3244+
// Add VIN voltage from client metadata if available
3245+
ClientMetadata *meta = findClientMetadata(rec.clientUid);
3246+
if (meta && meta->vinVoltage > 0.0f) {
3247+
clientObj["vinVoltage"] = meta->vinVoltage;
3248+
clientObj["vinVoltageEpoch"] = meta->vinVoltageEpoch;
3249+
}
32133250
}
32143251

32153252
const char *existingSite = clientObj.containsKey("site") ? clientObj["site"].as<const char *>() : nullptr;
@@ -3609,10 +3646,72 @@ static void handleAlarm(JsonDocument &doc, double epoch) {
36093646
}
36103647
}
36113648

3649+
// Helper function to find client metadata entry (read-only, does not create)
3650+
static ClientMetadata *findClientMetadata(const char *clientUid) {
3651+
if (!clientUid || strlen(clientUid) == 0) {
3652+
return nullptr;
3653+
}
3654+
3655+
for (uint8_t i = 0; i < gClientMetadataCount; ++i) {
3656+
if (strcmp(gClientMetadata[i].clientUid, clientUid) == 0) {
3657+
return &gClientMetadata[i];
3658+
}
3659+
}
3660+
3661+
return nullptr;
3662+
}
3663+
3664+
// Helper function to find or create client metadata entry
3665+
static ClientMetadata *findOrCreateClientMetadata(const char *clientUid) {
3666+
if (!clientUid || strlen(clientUid) == 0) {
3667+
return nullptr;
3668+
}
3669+
3670+
// Search for existing entry
3671+
ClientMetadata *existing = findClientMetadata(clientUid);
3672+
if (existing) {
3673+
return existing;
3674+
}
3675+
3676+
// Create new entry if space available
3677+
if (gClientMetadataCount < MAX_CLIENT_METADATA) {
3678+
ClientMetadata *meta = &gClientMetadata[gClientMetadataCount++];
3679+
memset(meta, 0, sizeof(ClientMetadata));
3680+
strlcpy(meta->clientUid, clientUid, sizeof(meta->clientUid));
3681+
return meta;
3682+
}
3683+
3684+
// Maximum client metadata capacity reached
3685+
Serial.print(F("Warning: Cannot create client metadata for "));
3686+
Serial.print(clientUid);
3687+
Serial.print(F(" - max capacity ("));
3688+
Serial.print(MAX_CLIENT_METADATA);
3689+
Serial.println(F(") reached"));
3690+
return nullptr;
3691+
}
3692+
36123693
static void handleDaily(JsonDocument &doc, double epoch) {
3613-
(void)doc;
3614-
(void)epoch;
3615-
// Daily reports are persisted in sendDailyEmail; nothing to do for inbound ack
3694+
// Extract VIN voltage from daily report if present
3695+
const char *clientUid = doc["client"] | "";
3696+
if (clientUid && strlen(clientUid) > 0) {
3697+
// Check if this is part 1 of the daily report (which contains vinVoltage)
3698+
uint8_t part = doc["part"].as<uint8_t>();
3699+
if (part == 1 && doc.containsKey("vinVoltage")) {
3700+
float vinVoltage = doc["vinVoltage"].as<float>();
3701+
if (vinVoltage > 0.0f) {
3702+
ClientMetadata *meta = findOrCreateClientMetadata(clientUid);
3703+
if (meta) {
3704+
meta->vinVoltage = vinVoltage;
3705+
meta->vinVoltageEpoch = (epoch > 0.0) ? epoch : currentEpoch();
3706+
Serial.print(F("VIN voltage received from "));
3707+
Serial.print(clientUid);
3708+
Serial.print(F(": "));
3709+
Serial.print(vinVoltage);
3710+
Serial.println(F(" V"));
3711+
}
3712+
}
3713+
}
3714+
}
36163715
}
36173716

36183717
static TankRecord *upsertTankRecord(const char *clientUid, uint8_t tankNumber) {
@@ -3804,6 +3903,12 @@ static void publishViewerSummary() {
38043903
obj["alarm"] = gTankRecords[i].alarmActive;
38053904
obj["alarmType"] = gTankRecords[i].alarmType;
38063905
obj["lastUpdate"] = gTankRecords[i].lastUpdateEpoch;
3906+
3907+
// Add VIN voltage from client metadata if available
3908+
ClientMetadata *meta = findClientMetadata(gTankRecords[i].clientUid);
3909+
if (meta && meta->vinVoltage > 0.0f) {
3910+
obj["vinVoltage"] = meta->vinVoltage;
3911+
}
38073912
}
38083913

38093914
String json;

TankAlarm-112025-Viewer-BluesOpta/TankAlarm-112025-Viewer-BluesOpta.ino

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ struct TankRecord {
113113
bool alarmActive;
114114
char alarmType[24];
115115
double lastUpdateEpoch;
116+
float vinVoltage; // Blues Notecard VIN voltage
116117
};
117118

118119
static const size_t TANK_JSON_CAPACITY = JSON_ARRAY_SIZE(MAX_TANK_RECORDS) + (MAX_TANK_RECORDS * JSON_OBJECT_SIZE(10)) + 768;
@@ -238,6 +239,7 @@ static const char VIEWER_DASHBOARD_HTML[] PROGMEM = R"HTML(
238239
<th>Site</th>
239240
<th>Tank</th>
240241
<th>Level (ft/in)</th>
242+
<th>VIN Voltage</th>
241243
<th>24hr Change</th>
242244
<th>Updated</th>
243245
</tr>
@@ -372,7 +374,7 @@ static const char VIEWER_DASHBOARD_HTML[] PROGMEM = R"HTML(
372374
const rows = state.selected ? state.tanks.filter(t => t.client === state.selected) : state.tanks;
373375
if (!rows.length) {
374376
const tr = document.createElement('tr');
375-
tr.innerHTML = '<td colspan="5">No tank data available</td>';
377+
tr.innerHTML = '<td colspan="6">No tank data available</td>';
376378
tbody.appendChild(tr);
377379
return;
378380
}
@@ -383,12 +385,20 @@ static const char VIEWER_DASHBOARD_HTML[] PROGMEM = R"HTML(
383385
<td>${escapeHtml(tank.site, '--')}</td>
384386
<td>${escapeHtml(tank.label || 'Tank')} #${escapeHtml((tank.tank ?? '?'))}</td>
385387
<td>${formatFeetInches(tank.levelInches)}</td>
388+
<td>${formatVoltage(tank.vinVoltage)}</td>
386389
<td>--</td>
387390
<td>${formatEpoch(tank.lastUpdate)}</td>`;
388391
tbody.appendChild(tr);
389392
});
390393
}
391394
395+
function formatVoltage(voltage) {
396+
if (typeof voltage !== 'number' || !isFinite(voltage) || voltage <= 0) {
397+
return '--';
398+
}
399+
return voltage.toFixed(2) + ' V';
400+
}
401+
392402
function statusBadge(tank) {
393403
if (!tank.alarm) {
394404
return '<span class="status-pill ok">Normal</span>';
@@ -815,6 +825,9 @@ static void sendTankJson(EthernetClient &client) {
815825
obj["alarm"] = gTankRecords[i].alarmActive;
816826
obj["alarmType"] = gTankRecords[i].alarmType;
817827
obj["lastUpdate"] = gTankRecords[i].lastUpdateEpoch;
828+
if (gTankRecords[i].vinVoltage > 0.0f) {
829+
obj["vinVoltage"] = gTankRecords[i].vinVoltage;
830+
}
818831
}
819832

820833
String body;
@@ -900,6 +913,7 @@ static void handleViewerSummary(JsonDocument &doc, double epoch) {
900913
rec.alarmActive = item["alarm"].as<bool>();
901914
strlcpy(rec.alarmType, item["alarmType"] | (rec.alarmActive ? "alarm" : "clear"), sizeof(rec.alarmType));
902915
rec.lastUpdateEpoch = item["lastUpdate"].as<double>();
916+
rec.vinVoltage = item["vinVoltage"].as<float>();
903917
}
904918
}
905919

0 commit comments

Comments
 (0)