@@ -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+
185199static ServerConfig gConfig ;
186200static 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();
25132541static void saveClientConfigSnapshots ();
25142542static void cacheClientConfigFromBuffer (const char *clientUid, const char *buffer);
25152543static ClientConfigSnapshot *findClientConfigSnapshot (const char *clientUid);
2544+ static ClientMetadata *findClientMetadata (const char *clientUid);
2545+ static ClientMetadata *findOrCreateClientMetadata (const char *clientUid);
25162546static bool checkSmsRateLimit (TankRecord *rec);
25172547static void publishViewerSummary ();
25182548static 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+
36123693static 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
36183717static 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;
0 commit comments