mirror of
https://github.com/smittix/intercept.git
synced 2026-05-30 02:59:27 -07:00
feat: Add meter grouping by device ID with consumption trends
Transform flat scrolling meter list into grouped view showing one card per unique meter with: - Consumption history tracking and delta from previous reading - Trend sparkline visualization (color-coded for normal/elevated/spike) - Consumption rate calculation (units/hour over 30-min window) - Cards update in place instead of creating duplicates - Alert sound only plays for new meters Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -998,6 +998,145 @@
|
|||||||
border-color: rgba(59, 130, 246, 0.25);
|
border-color: rgba(59, 130, 246, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
AGGREGATED METER CARD
|
||||||
|
============================================ */
|
||||||
|
.signal-card.meter-aggregated {
|
||||||
|
/* Inherit standard signal-card styles */
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-aggregated-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.2fr 0.8fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
border-left: 2px solid var(--accent-yellow);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-aggregated-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-aggregated-label {
|
||||||
|
font-size: 9px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-aggregated-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Consumption column */
|
||||||
|
.consumption-col .consumption-value {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delta badge */
|
||||||
|
.meter-delta {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
width: fit-content;
|
||||||
|
background: var(--bg-tertiary, rgba(255, 255, 255, 0.05));
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-delta.positive {
|
||||||
|
background: rgba(34, 197, 94, 0.15);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-delta.negative {
|
||||||
|
background: rgba(239, 68, 68, 0.15);
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sparkline container */
|
||||||
|
.meter-sparkline-container {
|
||||||
|
min-height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-sparkline-placeholder {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rate display */
|
||||||
|
.meter-rate-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--accent-cyan, #4a9eff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Update animation */
|
||||||
|
.signal-card.meter-updated {
|
||||||
|
animation: meterUpdatePulse 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes meterUpdatePulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(234, 179, 8, 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 0 4px rgba(234, 179, 8, 0.2);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(234, 179, 8, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Consumption sparkline styles */
|
||||||
|
.consumption-sparkline-svg {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consumption-sparkline-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consumption-trend {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments for aggregated meters */
|
||||||
|
@media (max-width: 500px) {
|
||||||
|
.meter-aggregated-grid {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-aggregated-col.trend-col {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-sparkline-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meter-sparkline-container svg {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
APRS SYMBOL
|
APRS SYMBOL
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|||||||
235
static/js/components/consumption-sparkline.js
Normal file
235
static/js/components/consumption-sparkline.js
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
/**
|
||||||
|
* Consumption Sparkline Component
|
||||||
|
* SVG-based visualization for meter consumption deltas
|
||||||
|
* Adapted from RSSISparkline pattern
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ConsumptionSparkline = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Default configuration
|
||||||
|
const DEFAULT_CONFIG = {
|
||||||
|
width: 100,
|
||||||
|
height: 28,
|
||||||
|
maxSamples: 20,
|
||||||
|
strokeWidth: 1.5,
|
||||||
|
showGradient: true,
|
||||||
|
barMode: true // Use bars instead of line for consumption
|
||||||
|
};
|
||||||
|
|
||||||
|
// Color thresholds for consumption deltas
|
||||||
|
// Green = normal/expected, Yellow = elevated, Red = spike
|
||||||
|
const DELTA_COLORS = {
|
||||||
|
normal: '#22c55e', // Green
|
||||||
|
elevated: '#eab308', // Yellow
|
||||||
|
spike: '#ef4444' // Red
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify a delta value relative to the average
|
||||||
|
* @param {number} delta - The delta value
|
||||||
|
* @param {number} avgDelta - Average delta for comparison
|
||||||
|
* @returns {string} - 'normal', 'elevated', or 'spike'
|
||||||
|
*/
|
||||||
|
function classifyDelta(delta, avgDelta) {
|
||||||
|
if (avgDelta === 0 || isNaN(avgDelta)) {
|
||||||
|
return delta === 0 ? 'normal' : 'elevated';
|
||||||
|
}
|
||||||
|
const ratio = Math.abs(delta) / Math.abs(avgDelta);
|
||||||
|
if (ratio <= 1.5) return 'normal';
|
||||||
|
if (ratio <= 3) return 'elevated';
|
||||||
|
return 'spike';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get color for a delta value
|
||||||
|
*/
|
||||||
|
function getDeltaColor(delta, avgDelta) {
|
||||||
|
const classification = classifyDelta(delta, avgDelta);
|
||||||
|
return DELTA_COLORS[classification];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create sparkline SVG for consumption deltas
|
||||||
|
* @param {Array<{timestamp, delta}>} deltas - Array of delta objects
|
||||||
|
* @param {Object} config - Configuration options
|
||||||
|
* @returns {string} - SVG HTML string
|
||||||
|
*/
|
||||||
|
function createSparklineSvg(deltas, config = {}) {
|
||||||
|
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
const { width, height, strokeWidth, showGradient, barMode } = cfg;
|
||||||
|
|
||||||
|
if (!deltas || deltas.length < 1) {
|
||||||
|
return createEmptySparkline(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract just the delta values
|
||||||
|
const values = deltas.map(d => d.delta);
|
||||||
|
|
||||||
|
// Calculate statistics for color classification
|
||||||
|
const avgDelta = values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
const maxDelta = Math.max(...values.map(Math.abs), 1);
|
||||||
|
|
||||||
|
if (barMode) {
|
||||||
|
return createBarSparkline(values, avgDelta, maxDelta, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return createLineSparkline(values, avgDelta, maxDelta, cfg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create bar-style sparkline (better for discrete readings)
|
||||||
|
*/
|
||||||
|
function createBarSparkline(values, avgDelta, maxDelta, cfg) {
|
||||||
|
const { width, height } = cfg;
|
||||||
|
const barCount = Math.min(values.length, cfg.maxSamples);
|
||||||
|
const displayValues = values.slice(-barCount);
|
||||||
|
|
||||||
|
const barWidth = Math.max(3, (width / barCount) - 1);
|
||||||
|
const barGap = 1;
|
||||||
|
|
||||||
|
let bars = '';
|
||||||
|
displayValues.forEach((val, i) => {
|
||||||
|
const normalizedHeight = (Math.abs(val) / maxDelta) * (height - 4);
|
||||||
|
const barHeight = Math.max(2, normalizedHeight);
|
||||||
|
const x = i * (barWidth + barGap);
|
||||||
|
const y = height - barHeight - 2;
|
||||||
|
const color = getDeltaColor(val, avgDelta);
|
||||||
|
|
||||||
|
bars += `<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}"
|
||||||
|
width="${barWidth.toFixed(1)}" height="${barHeight.toFixed(1)}"
|
||||||
|
fill="${color}" rx="1" opacity="0.85"/>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg class="consumption-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<line x1="0" y1="${height - 2}" x2="${width}" y2="${height - 2}"
|
||||||
|
stroke="#333" stroke-width="1" opacity="0.3"/>
|
||||||
|
${bars}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create line-style sparkline
|
||||||
|
*/
|
||||||
|
function createLineSparkline(values, avgDelta, maxDelta, cfg) {
|
||||||
|
const { width, height, strokeWidth, showGradient } = cfg;
|
||||||
|
const displayValues = values.slice(-cfg.maxSamples);
|
||||||
|
|
||||||
|
if (displayValues.length < 2) {
|
||||||
|
return createEmptySparkline(width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize values to 0-1 range
|
||||||
|
const normalized = displayValues.map(v => Math.abs(v) / maxDelta);
|
||||||
|
|
||||||
|
// Calculate path
|
||||||
|
const stepX = width / (normalized.length - 1);
|
||||||
|
let pathD = '';
|
||||||
|
let areaD = '';
|
||||||
|
const points = [];
|
||||||
|
|
||||||
|
normalized.forEach((val, i) => {
|
||||||
|
const x = i * stepX;
|
||||||
|
const y = height - (val * (height - 4)) - 2;
|
||||||
|
points.push({ x, y, value: displayValues[i] });
|
||||||
|
|
||||||
|
if (i === 0) {
|
||||||
|
pathD = `M${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
areaD = `M${x.toFixed(1)},${height} L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
} else {
|
||||||
|
pathD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
areaD += ` L${x.toFixed(1)},${y.toFixed(1)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
areaD += ` L${width},${height} Z`;
|
||||||
|
|
||||||
|
// Get color based on latest value
|
||||||
|
const latestValue = displayValues[displayValues.length - 1];
|
||||||
|
const strokeColor = getDeltaColor(latestValue, avgDelta);
|
||||||
|
const gradientId = `consumption-gradient-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
|
||||||
|
let gradientDef = '';
|
||||||
|
if (showGradient) {
|
||||||
|
gradientDef = `
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="${gradientId}" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:${strokeColor};stop-opacity:0.3"/>
|
||||||
|
<stop offset="100%" style="stop-color:${strokeColor};stop-opacity:0.05"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg class="consumption-sparkline-svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
${gradientDef}
|
||||||
|
${showGradient ? `<path d="${areaD}" fill="url(#${gradientId})" />` : ''}
|
||||||
|
<path d="${pathD}" fill="none" stroke="${strokeColor}" stroke-width="${strokeWidth}"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<circle cx="${points[points.length - 1].x}" cy="${points[points.length - 1].y}"
|
||||||
|
r="2.5" fill="${strokeColor}" class="sparkline-dot" />
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create empty sparkline placeholder
|
||||||
|
*/
|
||||||
|
function createEmptySparkline(width, height) {
|
||||||
|
return `
|
||||||
|
<svg class="consumption-sparkline-svg consumption-sparkline-empty" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
|
||||||
|
<line x1="0" y1="${height / 2}" x2="${width}" y2="${height / 2}"
|
||||||
|
stroke="#444" stroke-width="1" stroke-dasharray="3,3" />
|
||||||
|
<text x="${width / 2}" y="${height / 2 + 4}" text-anchor="middle"
|
||||||
|
fill="#555" font-size="9" font-family="monospace">Collecting...</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create sparkline with summary stats
|
||||||
|
* @param {Array} deltas - Delta history
|
||||||
|
* @param {Object} options - Display options
|
||||||
|
* @returns {string} - HTML string
|
||||||
|
*/
|
||||||
|
function createSparklineWithStats(deltas, options = {}) {
|
||||||
|
const svg = createSparklineSvg(deltas, options);
|
||||||
|
|
||||||
|
if (!deltas || deltas.length < 2) {
|
||||||
|
return `<div class="consumption-sparkline-wrapper">${svg}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate trend
|
||||||
|
const recentDeltas = deltas.slice(-5);
|
||||||
|
const avgRecent = recentDeltas.reduce((a, d) => a + d.delta, 0) / recentDeltas.length;
|
||||||
|
const trend = avgRecent > 0 ? 'up' : avgRecent < 0 ? 'down' : 'stable';
|
||||||
|
const trendIcon = trend === 'up' ? '↑' : trend === 'down' ? '↓' : '↔';
|
||||||
|
const trendColor = trend === 'up' ? '#22c55e' : trend === 'down' ? '#ef4444' : '#888';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="consumption-sparkline-wrapper">
|
||||||
|
${svg}
|
||||||
|
<span class="consumption-trend" style="color: ${trendColor}" title="Recent trend">
|
||||||
|
${trendIcon}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
createSparklineSvg,
|
||||||
|
createEmptySparkline,
|
||||||
|
createSparklineWithStats,
|
||||||
|
classifyDelta,
|
||||||
|
getDeltaColor,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
DELTA_COLORS
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.ConsumptionSparkline = ConsumptionSparkline;
|
||||||
278
static/js/components/meter-aggregator.js
Normal file
278
static/js/components/meter-aggregator.js
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* Meter Aggregator Component
|
||||||
|
* Client-side aggregation for rtlamr meter readings
|
||||||
|
* Groups readings by meter ID and tracks consumption history
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MeterAggregator = (function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const CONFIG = {
|
||||||
|
maxHistoryAge: 60 * 60 * 1000, // 60 minutes
|
||||||
|
maxHistoryLength: 50, // Max readings to keep per meter
|
||||||
|
rateWindowMs: 30 * 60 * 1000 // 30 minutes for rate calculation
|
||||||
|
};
|
||||||
|
|
||||||
|
// Storage for aggregated meters
|
||||||
|
// Map<meterId, MeterData>
|
||||||
|
const meters = new Map();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MeterData structure:
|
||||||
|
* {
|
||||||
|
* id: string,
|
||||||
|
* type: string,
|
||||||
|
* utility: string,
|
||||||
|
* manufacturer: string,
|
||||||
|
* firstSeen: number (timestamp),
|
||||||
|
* lastSeen: number (timestamp),
|
||||||
|
* readingCount: number,
|
||||||
|
* latestReading: object (full reading data),
|
||||||
|
* history: Array<{timestamp, consumption, raw}>,
|
||||||
|
* delta: number | null (change from previous reading),
|
||||||
|
* rate: number | null (units per hour)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ingest a new meter reading
|
||||||
|
* @param {Object} data - The raw meter reading data
|
||||||
|
* @returns {Object} - { meter: MeterData, isNew: boolean }
|
||||||
|
*/
|
||||||
|
function ingest(data) {
|
||||||
|
const msgData = data.Message || {};
|
||||||
|
const meterId = String(msgData.ID || data.id || 'Unknown');
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const consumption = msgData.Consumption !== undefined ? msgData.Consumption : data.consumption;
|
||||||
|
|
||||||
|
// Get meter type info if available
|
||||||
|
const meterInfo = typeof getMeterTypeInfo === 'function'
|
||||||
|
? getMeterTypeInfo(msgData.EndpointType, data.Type)
|
||||||
|
: { utility: 'Unknown', manufacturer: 'Unknown' };
|
||||||
|
|
||||||
|
const existing = meters.get(meterId);
|
||||||
|
const isNew = !existing;
|
||||||
|
|
||||||
|
if (isNew) {
|
||||||
|
// Create new meter entry
|
||||||
|
const meter = {
|
||||||
|
id: meterId,
|
||||||
|
type: data.Type || 'Unknown',
|
||||||
|
utility: meterInfo.utility,
|
||||||
|
manufacturer: meterInfo.manufacturer,
|
||||||
|
firstSeen: timestamp,
|
||||||
|
lastSeen: timestamp,
|
||||||
|
readingCount: 1,
|
||||||
|
latestReading: data,
|
||||||
|
history: [{
|
||||||
|
timestamp: timestamp,
|
||||||
|
consumption: consumption,
|
||||||
|
raw: data
|
||||||
|
}],
|
||||||
|
delta: null,
|
||||||
|
rate: null
|
||||||
|
};
|
||||||
|
meters.set(meterId, meter);
|
||||||
|
return { meter, isNew: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing meter
|
||||||
|
const previousConsumption = existing.history.length > 0
|
||||||
|
? existing.history[existing.history.length - 1].consumption
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Add to history
|
||||||
|
existing.history.push({
|
||||||
|
timestamp: timestamp,
|
||||||
|
consumption: consumption,
|
||||||
|
raw: data
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prune old history
|
||||||
|
pruneHistory(existing);
|
||||||
|
|
||||||
|
// Calculate delta (change from previous reading)
|
||||||
|
if (previousConsumption !== null && consumption !== undefined && consumption !== null) {
|
||||||
|
existing.delta = consumption - previousConsumption;
|
||||||
|
} else {
|
||||||
|
existing.delta = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate rate (units per hour)
|
||||||
|
existing.rate = calculateRate(existing);
|
||||||
|
|
||||||
|
// Update meter data
|
||||||
|
existing.lastSeen = timestamp;
|
||||||
|
existing.readingCount++;
|
||||||
|
existing.latestReading = data;
|
||||||
|
existing.type = data.Type || existing.type;
|
||||||
|
if (meterInfo.utility !== 'Unknown') existing.utility = meterInfo.utility;
|
||||||
|
if (meterInfo.manufacturer !== 'Unknown') existing.manufacturer = meterInfo.manufacturer;
|
||||||
|
|
||||||
|
return { meter: existing, isNew: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prune history older than maxHistoryAge and beyond maxHistoryLength
|
||||||
|
*/
|
||||||
|
function pruneHistory(meter) {
|
||||||
|
const cutoff = Date.now() - CONFIG.maxHistoryAge;
|
||||||
|
|
||||||
|
// Remove old entries
|
||||||
|
meter.history = meter.history.filter(h => h.timestamp >= cutoff);
|
||||||
|
|
||||||
|
// Limit length
|
||||||
|
if (meter.history.length > CONFIG.maxHistoryLength) {
|
||||||
|
meter.history = meter.history.slice(-CONFIG.maxHistoryLength);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate consumption rate over the rate window
|
||||||
|
* @returns {number|null} Units per hour, or null if insufficient data
|
||||||
|
*/
|
||||||
|
function calculateRate(meter) {
|
||||||
|
if (meter.history.length < 2) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - CONFIG.rateWindowMs;
|
||||||
|
|
||||||
|
// Find readings within the rate window
|
||||||
|
const recentHistory = meter.history.filter(h => h.timestamp >= windowStart);
|
||||||
|
if (recentHistory.length < 2) return null;
|
||||||
|
|
||||||
|
const oldest = recentHistory[0];
|
||||||
|
const newest = recentHistory[recentHistory.length - 1];
|
||||||
|
|
||||||
|
// Need both to have valid consumption values
|
||||||
|
if (oldest.consumption === undefined || oldest.consumption === null ||
|
||||||
|
newest.consumption === undefined || newest.consumption === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumptionDiff = newest.consumption - oldest.consumption;
|
||||||
|
const timeDiffHours = (newest.timestamp - oldest.timestamp) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (timeDiffHours <= 0) return null;
|
||||||
|
|
||||||
|
return consumptionDiff / timeDiffHours;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get consumption deltas for sparkline display
|
||||||
|
* @returns {Array<{timestamp, delta}>}
|
||||||
|
*/
|
||||||
|
function getConsumptionDeltas(meter) {
|
||||||
|
const deltas = [];
|
||||||
|
for (let i = 1; i < meter.history.length; i++) {
|
||||||
|
const prev = meter.history[i - 1];
|
||||||
|
const curr = meter.history[i];
|
||||||
|
if (prev.consumption !== undefined && prev.consumption !== null &&
|
||||||
|
curr.consumption !== undefined && curr.consumption !== null) {
|
||||||
|
deltas.push({
|
||||||
|
timestamp: curr.timestamp,
|
||||||
|
delta: curr.consumption - prev.consumption
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deltas;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a meter by ID
|
||||||
|
* @param {string} id
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
function getMeter(id) {
|
||||||
|
return meters.get(String(id)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all meters
|
||||||
|
* @returns {Array<Object>}
|
||||||
|
*/
|
||||||
|
function getAllMeters() {
|
||||||
|
return Array.from(meters.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get meter count
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function getCount() {
|
||||||
|
return meters.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all aggregated data
|
||||||
|
*/
|
||||||
|
function clear() {
|
||||||
|
meters.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get time since last reading for a meter
|
||||||
|
* @param {Object} meter
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function getTimeSinceLastReading(meter) {
|
||||||
|
const diff = Date.now() - meter.lastSeen;
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) return 'Just now';
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format rate for display
|
||||||
|
* @param {number|null} rate
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatRate(rate) {
|
||||||
|
if (rate === null || rate === undefined || isNaN(rate)) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
// Format based on magnitude
|
||||||
|
const absRate = Math.abs(rate);
|
||||||
|
if (absRate >= 100) {
|
||||||
|
return rate.toFixed(0) + '/hr';
|
||||||
|
} else if (absRate >= 1) {
|
||||||
|
return rate.toFixed(1) + '/hr';
|
||||||
|
} else {
|
||||||
|
return rate.toFixed(2) + '/hr';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format delta for display
|
||||||
|
* @param {number|null} delta
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatDelta(delta) {
|
||||||
|
if (delta === null || delta === undefined || isNaN(delta)) {
|
||||||
|
return '--';
|
||||||
|
}
|
||||||
|
const sign = delta >= 0 ? '+' : '';
|
||||||
|
return sign + delta.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
ingest,
|
||||||
|
getMeter,
|
||||||
|
getAllMeters,
|
||||||
|
getCount,
|
||||||
|
clear,
|
||||||
|
getConsumptionDeltas,
|
||||||
|
getTimeSinceLastReading,
|
||||||
|
formatRate,
|
||||||
|
formatDelta,
|
||||||
|
CONFIG
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make globally available
|
||||||
|
window.MeterAggregator = MeterAggregator;
|
||||||
@@ -1155,6 +1155,303 @@ const SignalCards = (function() {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create an aggregated utility meter card (grouped by meter ID)
|
||||||
|
* Shows consumption history, sparkline, delta, and rate
|
||||||
|
* @param {Object} meter - Aggregated meter data from MeterAggregator
|
||||||
|
* @param {Object} options - Optional configuration
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
function createAggregatedMeterCard(meter, options = {}) {
|
||||||
|
const status = meter.readingCount === 1 ? 'new' : 'baseline';
|
||||||
|
const relativeTime = MeterAggregator.getTimeSinceLastReading(meter);
|
||||||
|
|
||||||
|
const card = document.createElement('article');
|
||||||
|
card.className = 'signal-card meter-aggregated';
|
||||||
|
card.dataset.status = status;
|
||||||
|
card.dataset.type = 'meter';
|
||||||
|
card.dataset.protocol = meter.type || 'unknown';
|
||||||
|
card.dataset.meterId = meter.id;
|
||||||
|
card.id = 'metercard_' + meter.id;
|
||||||
|
|
||||||
|
// Determine meter type color
|
||||||
|
let meterTypeClass = 'electric';
|
||||||
|
const utility = (meter.utility || '').toLowerCase();
|
||||||
|
const meterType = (meter.type || '').toLowerCase();
|
||||||
|
if (utility === 'gas' || meterType.includes('gas')) {
|
||||||
|
meterTypeClass = 'gas';
|
||||||
|
} else if (utility === 'water' || meterType.includes('water') || meterType.includes('r900')) {
|
||||||
|
meterTypeClass = 'water';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format utility display
|
||||||
|
const utilityDisplay = meter.utility && meter.utility !== 'Unknown' ? meter.utility : null;
|
||||||
|
const manufacturerDisplay = meter.manufacturer && meter.manufacturer !== 'Unknown' ? meter.manufacturer : null;
|
||||||
|
|
||||||
|
// Get consumption deltas for sparkline
|
||||||
|
const deltas = typeof MeterAggregator !== 'undefined'
|
||||||
|
? MeterAggregator.getConsumptionDeltas(meter)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Create sparkline
|
||||||
|
const sparklineHtml = typeof ConsumptionSparkline !== 'undefined'
|
||||||
|
? ConsumptionSparkline.createSparklineSvg(deltas, { width: 100, height: 28 })
|
||||||
|
: '<span class="meter-sparkline-placeholder">--</span>';
|
||||||
|
|
||||||
|
// Format delta and rate
|
||||||
|
const deltaFormatted = MeterAggregator.formatDelta(meter.delta);
|
||||||
|
const rateFormatted = MeterAggregator.formatRate(meter.rate);
|
||||||
|
const deltaClass = meter.delta === null ? '' : (meter.delta >= 0 ? 'positive' : 'negative');
|
||||||
|
|
||||||
|
// Get latest consumption
|
||||||
|
const latestConsumption = meter.history.length > 0
|
||||||
|
? meter.history[meter.history.length - 1].consumption
|
||||||
|
: null;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="signal-card-header">
|
||||||
|
<div class="signal-card-badges">
|
||||||
|
<span class="signal-proto-badge meter ${meterTypeClass}">${escapeHtml(utilityDisplay || meter.type || 'Meter')}</span>
|
||||||
|
<span class="signal-freq-badge">ID: ${escapeHtml(meter.id || 'N/A')}</span>
|
||||||
|
${meter.readingCount > 1 ? `<span class="signal-seen-count">×${meter.readingCount}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
${status === 'new' ? `
|
||||||
|
<span class="signal-status-pill" data-status="new">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
New
|
||||||
|
</span>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="signal-card-body">
|
||||||
|
<div class="signal-meta-row">
|
||||||
|
${manufacturerDisplay ? `<span class="signal-msg-type">${escapeHtml(manufacturerDisplay)}</span>` : ''}
|
||||||
|
${meter.type ? `<span class="signal-msg-type" style="opacity: 0.7">${escapeHtml(meter.type)}</span>` : ''}
|
||||||
|
<span class="signal-timestamp meter-last-seen" data-timestamp="${meter.lastSeen}">${escapeHtml(relativeTime)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter-aggregated-grid">
|
||||||
|
<div class="meter-aggregated-col consumption-col">
|
||||||
|
<span class="meter-aggregated-label">Consumption</span>
|
||||||
|
<span class="meter-aggregated-value consumption-value">${latestConsumption !== null ? latestConsumption.toLocaleString() : '--'}</span>
|
||||||
|
<span class="meter-delta ${deltaClass}" title="Change from previous reading">${deltaFormatted}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meter-aggregated-col trend-col">
|
||||||
|
<span class="meter-aggregated-label">Trend</span>
|
||||||
|
<div class="meter-sparkline-container">
|
||||||
|
${sparklineHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="meter-aggregated-col rate-col">
|
||||||
|
<span class="meter-aggregated-label">Rate</span>
|
||||||
|
<span class="meter-rate-value">${rateFormatted}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-card-footer">
|
||||||
|
<button class="signal-advanced-toggle" onclick="SignalCards.toggleAdvanced(this)">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M6 9l6 6 6-6"/>
|
||||||
|
</svg>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
<div class="signal-card-actions">
|
||||||
|
<button class="signal-action-btn" onclick="SignalCards.muteAddress('${escapeHtml(meter.id)}')">Mute</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-panel">
|
||||||
|
<div class="signal-advanced-inner">
|
||||||
|
<div class="signal-advanced-content">
|
||||||
|
<div class="signal-advanced-section">
|
||||||
|
<div class="signal-advanced-title">Meter Details</div>
|
||||||
|
<div class="signal-advanced-grid">
|
||||||
|
${buildAggregatedMeterDetailsHtml(meter)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing aggregated meter card in place
|
||||||
|
* @param {HTMLElement} card - The card element to update
|
||||||
|
* @param {Object} meter - Updated meter data from MeterAggregator
|
||||||
|
*/
|
||||||
|
function updateAggregatedMeterCard(card, meter) {
|
||||||
|
if (!card || !meter) return;
|
||||||
|
|
||||||
|
// Update timestamp
|
||||||
|
const relativeTime = MeterAggregator.getTimeSinceLastReading(meter);
|
||||||
|
const timestampEl = card.querySelector('.meter-last-seen');
|
||||||
|
if (timestampEl) {
|
||||||
|
timestampEl.dataset.timestamp = meter.lastSeen;
|
||||||
|
timestampEl.textContent = relativeTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update seen count badge
|
||||||
|
const seenCountEl = card.querySelector('.signal-seen-count');
|
||||||
|
if (seenCountEl) {
|
||||||
|
seenCountEl.innerHTML = `×${meter.readingCount}`;
|
||||||
|
} else if (meter.readingCount > 1) {
|
||||||
|
// Add seen count if it doesn't exist
|
||||||
|
const badges = card.querySelector('.signal-card-badges');
|
||||||
|
if (badges) {
|
||||||
|
const countSpan = document.createElement('span');
|
||||||
|
countSpan.className = 'signal-seen-count';
|
||||||
|
countSpan.innerHTML = `×${meter.readingCount}`;
|
||||||
|
badges.appendChild(countSpan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove "new" status pill after first update
|
||||||
|
if (meter.readingCount > 1) {
|
||||||
|
card.dataset.status = 'baseline';
|
||||||
|
const statusPill = card.querySelector('.signal-status-pill[data-status="new"]');
|
||||||
|
if (statusPill) {
|
||||||
|
statusPill.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update consumption value
|
||||||
|
const latestConsumption = meter.history.length > 0
|
||||||
|
? meter.history[meter.history.length - 1].consumption
|
||||||
|
: null;
|
||||||
|
const consumptionEl = card.querySelector('.consumption-value');
|
||||||
|
if (consumptionEl) {
|
||||||
|
consumptionEl.textContent = latestConsumption !== null ? latestConsumption.toLocaleString() : '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update delta
|
||||||
|
const deltaEl = card.querySelector('.meter-delta');
|
||||||
|
if (deltaEl) {
|
||||||
|
const deltaFormatted = MeterAggregator.formatDelta(meter.delta);
|
||||||
|
deltaEl.textContent = deltaFormatted;
|
||||||
|
deltaEl.classList.remove('positive', 'negative');
|
||||||
|
if (meter.delta !== null) {
|
||||||
|
deltaEl.classList.add(meter.delta >= 0 ? 'positive' : 'negative');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update sparkline
|
||||||
|
const sparklineContainer = card.querySelector('.meter-sparkline-container');
|
||||||
|
if (sparklineContainer && typeof ConsumptionSparkline !== 'undefined') {
|
||||||
|
const deltas = MeterAggregator.getConsumptionDeltas(meter);
|
||||||
|
sparklineContainer.innerHTML = ConsumptionSparkline.createSparklineSvg(deltas, { width: 100, height: 28 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update rate
|
||||||
|
const rateEl = card.querySelector('.meter-rate-value');
|
||||||
|
if (rateEl) {
|
||||||
|
rateEl.textContent = MeterAggregator.formatRate(meter.rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update details panel
|
||||||
|
const detailsGrid = card.querySelector('.signal-advanced-grid');
|
||||||
|
if (detailsGrid) {
|
||||||
|
detailsGrid.innerHTML = buildAggregatedMeterDetailsHtml(meter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add subtle update animation
|
||||||
|
card.classList.add('meter-updated');
|
||||||
|
setTimeout(() => card.classList.remove('meter-updated'), 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build HTML for aggregated meter detail fields
|
||||||
|
* @param {Object} meter - Aggregated meter data
|
||||||
|
* @returns {string} - HTML string
|
||||||
|
*/
|
||||||
|
function buildAggregatedMeterDetailsHtml(meter) {
|
||||||
|
let html = '';
|
||||||
|
const latestReading = meter.latestReading || {};
|
||||||
|
const rawMessage = latestReading.Message || {};
|
||||||
|
|
||||||
|
// Add device intelligence info at the top
|
||||||
|
if (meter.utility && meter.utility !== 'Unknown') {
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Utility Type</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(meter.utility)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
if (meter.manufacturer && meter.manufacturer !== 'Unknown') {
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Manufacturer</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(meter.manufacturer)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add aggregation stats
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Total Readings</span>
|
||||||
|
<span class="signal-advanced-value">${meter.readingCount}</span>
|
||||||
|
</div>
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">First Seen</span>
|
||||||
|
<span class="signal-advanced-value">${new Date(meter.firstSeen).toLocaleTimeString()}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add rate info if available
|
||||||
|
if (meter.rate !== null) {
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Consumption Rate</span>
|
||||||
|
<span class="signal-advanced-value">${MeterAggregator.formatRate(meter.rate)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display fields from the raw rtlamr message
|
||||||
|
for (const [key, value] of Object.entries(rawMessage)) {
|
||||||
|
if (value === null || value === undefined) continue;
|
||||||
|
|
||||||
|
// Format the label
|
||||||
|
const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, s => s.toUpperCase()).trim();
|
||||||
|
|
||||||
|
// Format the value
|
||||||
|
let displayValue;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (value.length > 10) {
|
||||||
|
displayValue = `[${value.length} values] ${value.slice(0, 5).join(', ')}...`;
|
||||||
|
} else {
|
||||||
|
displayValue = value.join(', ');
|
||||||
|
}
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
displayValue = JSON.stringify(value);
|
||||||
|
} else if (key === 'Consumption') {
|
||||||
|
displayValue = `${value.toLocaleString()} units`;
|
||||||
|
} else {
|
||||||
|
displayValue = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">${escapeHtml(label)}</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(displayValue)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add message type if not in raw message
|
||||||
|
if (!rawMessage.Type && meter.type) {
|
||||||
|
html += `
|
||||||
|
<div class="signal-advanced-item">
|
||||||
|
<span class="signal-advanced-label">Message Type</span>
|
||||||
|
<span class="signal-advanced-value">${escapeHtml(meter.type)}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle advanced panel on a card
|
* Toggle advanced panel on a card
|
||||||
*/
|
*/
|
||||||
@@ -1946,6 +2243,8 @@ const SignalCards = (function() {
|
|||||||
createSensorCard,
|
createSensorCard,
|
||||||
createAcarsCard,
|
createAcarsCard,
|
||||||
createMeterCard,
|
createMeterCard,
|
||||||
|
createAggregatedMeterCard,
|
||||||
|
updateAggregatedMeterCard,
|
||||||
|
|
||||||
// Signal classification
|
// Signal classification
|
||||||
SignalClassification,
|
SignalClassification,
|
||||||
|
|||||||
@@ -1768,6 +1768,8 @@
|
|||||||
<script src="{{ url_for('static', filename='js/components/timeline-adapters/wifi-adapter.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/components/timeline-adapters/wifi-adapter.js') }}"></script>
|
||||||
<!-- Bluetooth v2 components -->
|
<!-- Bluetooth v2 components -->
|
||||||
<script src="{{ url_for('static', filename='js/components/rssi-sparkline.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/components/rssi-sparkline.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/components/consumption-sparkline.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/components/meter-aggregator.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/components/message-card.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/components/message-card.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/components/device-card.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/components/device-card.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/components/proximity-radar.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/components/proximity-radar.js') }}"></script>
|
||||||
@@ -3072,17 +3074,18 @@
|
|||||||
const placeholder = output.querySelector('.placeholder');
|
const placeholder = output.querySelector('.placeholder');
|
||||||
if (placeholder) placeholder.remove();
|
if (placeholder) placeholder.remove();
|
||||||
|
|
||||||
// Store for export
|
// Store for export (all raw readings)
|
||||||
allMessages.push(data);
|
allMessages.push(data);
|
||||||
playAlert();
|
|
||||||
pulseSignal();
|
pulseSignal();
|
||||||
|
|
||||||
sensorCount++;
|
sensorCount++;
|
||||||
document.getElementById('sensorCount').textContent = sensorCount;
|
document.getElementById('sensorCount').textContent = sensorCount;
|
||||||
|
|
||||||
|
// Aggregate meter data using MeterAggregator
|
||||||
|
const { meter, isNew } = MeterAggregator.ingest(data);
|
||||||
|
|
||||||
// Track unique meters by ID
|
// Track unique meters by ID
|
||||||
const msgData = data.Message || {};
|
const meterId = meter.id;
|
||||||
const meterId = msgData.ID || 'Unknown';
|
|
||||||
if (meterId !== 'Unknown') {
|
if (meterId !== 'Unknown') {
|
||||||
const deviceKey = 'METER_' + meterId;
|
const deviceKey = 'METER_' + meterId;
|
||||||
if (!uniqueDevices.has(deviceKey)) {
|
if (!uniqueDevices.has(deviceKey)) {
|
||||||
@@ -3091,36 +3094,34 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get meter type info for display
|
// Check if card already exists for this meter
|
||||||
const meterInfo = typeof getMeterTypeInfo === 'function'
|
const existingCard = document.getElementById('metercard_' + meterId);
|
||||||
? getMeterTypeInfo(msgData.EndpointType, data.Type)
|
|
||||||
: { utility: 'Unknown', manufacturer: 'Unknown' };
|
|
||||||
|
|
||||||
// Convert rtlamr data to our card format, preserving all raw fields
|
if (existingCard) {
|
||||||
const msg = {
|
// Update existing card in place
|
||||||
id: String(meterId),
|
SignalCards.updateAggregatedMeterCard(existingCard, meter);
|
||||||
type: data.Type || 'Unknown',
|
} else {
|
||||||
consumption: msgData.Consumption,
|
// Create new aggregated meter card
|
||||||
unit: 'units',
|
const card = SignalCards.createAggregatedMeterCard(meter);
|
||||||
endpoint_type: msgData.EndpointType,
|
output.insertBefore(card, output.firstChild);
|
||||||
endpoint_id: msgData.EndpointID,
|
|
||||||
utility: meterInfo.utility,
|
|
||||||
manufacturer: meterInfo.manufacturer,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
rawMessage: msgData // Include all original fields for detailed display
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create card using SignalCards component
|
// Only play alert for new meters (not updates)
|
||||||
const card = SignalCards.createMeterCard(msg);
|
playAlert();
|
||||||
output.insertBefore(card, output.firstChild);
|
}
|
||||||
|
|
||||||
// Update filter counts
|
// Update filter counts
|
||||||
SignalCards.updateCounts(output);
|
SignalCards.updateCounts(output);
|
||||||
|
|
||||||
// Limit output to 50 cards
|
// Limit to max 50 unique meters (cards won't pile up since we update in place)
|
||||||
const cards = output.querySelectorAll('.signal-card');
|
const cards = output.querySelectorAll('.signal-card.meter-aggregated');
|
||||||
while (cards.length > 50) {
|
while (cards.length > 50) {
|
||||||
output.removeChild(output.lastChild);
|
// Remove oldest card (last one)
|
||||||
|
const oldestCard = output.querySelector('.signal-card.meter-aggregated:last-of-type');
|
||||||
|
if (oldestCard) {
|
||||||
|
output.removeChild(oldestCard);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3995,6 +3996,11 @@
|
|||||||
document.getElementById('sensorCount').textContent = '0';
|
document.getElementById('sensorCount').textContent = '0';
|
||||||
document.getElementById('deviceCount').textContent = '0';
|
document.getElementById('deviceCount').textContent = '0';
|
||||||
|
|
||||||
|
// Clear meter aggregator data
|
||||||
|
if (typeof MeterAggregator !== 'undefined') {
|
||||||
|
MeterAggregator.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// Reset recon data
|
// Reset recon data
|
||||||
deviceDatabase.clear();
|
deviceDatabase.clear();
|
||||||
newDeviceAlerts = 0;
|
newDeviceAlerts = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user