diff --git a/static/css/components/signal-cards.css b/static/css/components/signal-cards.css index 16be715..822d23d 100644 --- a/static/css/components/signal-cards.css +++ b/static/css/components/signal-cards.css @@ -998,6 +998,145 @@ 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 ============================================ */ diff --git a/static/js/components/consumption-sparkline.js b/static/js/components/consumption-sparkline.js new file mode 100644 index 0000000..6922626 --- /dev/null +++ b/static/js/components/consumption-sparkline.js @@ -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 += ``; + }); + + return ` + + + ${bars} + + `; + } + + /** + * 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 = ` + + + + + + + `; + } + + return ` + + ${gradientDef} + ${showGradient ? `` : ''} + + + + `; + } + + /** + * Create empty sparkline placeholder + */ + function createEmptySparkline(width, height) { + return ` + + + Collecting... + + `; + } + + /** + * 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 `
${svg}
`; + } + + // 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 ` +
+ ${svg} + + ${trendIcon} + +
+ `; + } + + // Public API + return { + createSparklineSvg, + createEmptySparkline, + createSparklineWithStats, + classifyDelta, + getDeltaColor, + DEFAULT_CONFIG, + DELTA_COLORS + }; +})(); + +// Make globally available +window.ConsumptionSparkline = ConsumptionSparkline; diff --git a/static/js/components/meter-aggregator.js b/static/js/components/meter-aggregator.js new file mode 100644 index 0000000..ac6e156 --- /dev/null +++ b/static/js/components/meter-aggregator.js @@ -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 + 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} + */ + 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; diff --git a/static/js/components/signal-cards.js b/static/js/components/signal-cards.js index 9087af3..d7505d6 100644 --- a/static/js/components/signal-cards.js +++ b/static/js/components/signal-cards.js @@ -1155,6 +1155,303 @@ const SignalCards = (function() { 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 }) + : '--'; + + // 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 = ` +
+
+ ${escapeHtml(utilityDisplay || meter.type || 'Meter')} + ID: ${escapeHtml(meter.id || 'N/A')} + ${meter.readingCount > 1 ? `×${meter.readingCount}` : ''} +
+ ${status === 'new' ? ` + + + New + + ` : ''} +
+
+
+ ${manufacturerDisplay ? `${escapeHtml(manufacturerDisplay)}` : ''} + ${meter.type ? `${escapeHtml(meter.type)}` : ''} + ${escapeHtml(relativeTime)} +
+
+
+ Consumption + ${latestConsumption !== null ? latestConsumption.toLocaleString() : '--'} + ${deltaFormatted} +
+
+ Trend +
+ ${sparklineHtml} +
+
+
+ Rate + ${rateFormatted} +
+
+
+ +
+
+
+
+
Meter Details
+
+ ${buildAggregatedMeterDetailsHtml(meter)} +
+
+
+
+
+ `; + + 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 += ` +
+ Utility Type + ${escapeHtml(meter.utility)} +
+ `; + } + if (meter.manufacturer && meter.manufacturer !== 'Unknown') { + html += ` +
+ Manufacturer + ${escapeHtml(meter.manufacturer)} +
+ `; + } + + // Add aggregation stats + html += ` +
+ Total Readings + ${meter.readingCount} +
+
+ First Seen + ${new Date(meter.firstSeen).toLocaleTimeString()} +
+ `; + + // Add rate info if available + if (meter.rate !== null) { + html += ` +
+ Consumption Rate + ${MeterAggregator.formatRate(meter.rate)} +
+ `; + } + + // 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 += ` +
+ ${escapeHtml(label)} + ${escapeHtml(displayValue)} +
+ `; + } + + // Add message type if not in raw message + if (!rawMessage.Type && meter.type) { + html += ` +
+ Message Type + ${escapeHtml(meter.type)} +
+ `; + } + + return html; + } + /** * Toggle advanced panel on a card */ @@ -1946,6 +2243,8 @@ const SignalCards = (function() { createSensorCard, createAcarsCard, createMeterCard, + createAggregatedMeterCard, + updateAggregatedMeterCard, // Signal classification SignalClassification, diff --git a/templates/index.html b/templates/index.html index 1a8f4d2..21b8831 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1768,6 +1768,8 @@ + + @@ -3072,17 +3074,18 @@ const placeholder = output.querySelector('.placeholder'); if (placeholder) placeholder.remove(); - // Store for export + // Store for export (all raw readings) allMessages.push(data); - playAlert(); pulseSignal(); sensorCount++; document.getElementById('sensorCount').textContent = sensorCount; + // Aggregate meter data using MeterAggregator + const { meter, isNew } = MeterAggregator.ingest(data); + // Track unique meters by ID - const msgData = data.Message || {}; - const meterId = msgData.ID || 'Unknown'; + const meterId = meter.id; if (meterId !== 'Unknown') { const deviceKey = 'METER_' + meterId; if (!uniqueDevices.has(deviceKey)) { @@ -3091,36 +3094,34 @@ } } - // Get meter type info for display - const meterInfo = typeof getMeterTypeInfo === 'function' - ? getMeterTypeInfo(msgData.EndpointType, data.Type) - : { utility: 'Unknown', manufacturer: 'Unknown' }; + // Check if card already exists for this meter + const existingCard = document.getElementById('metercard_' + meterId); - // Convert rtlamr data to our card format, preserving all raw fields - const msg = { - id: String(meterId), - type: data.Type || 'Unknown', - consumption: msgData.Consumption, - unit: 'units', - endpoint_type: msgData.EndpointType, - endpoint_id: msgData.EndpointID, - utility: meterInfo.utility, - manufacturer: meterInfo.manufacturer, - timestamp: new Date().toISOString(), - rawMessage: msgData // Include all original fields for detailed display - }; + if (existingCard) { + // Update existing card in place + SignalCards.updateAggregatedMeterCard(existingCard, meter); + } else { + // Create new aggregated meter card + const card = SignalCards.createAggregatedMeterCard(meter); + output.insertBefore(card, output.firstChild); - // Create card using SignalCards component - const card = SignalCards.createMeterCard(msg); - output.insertBefore(card, output.firstChild); + // Only play alert for new meters (not updates) + playAlert(); + } // Update filter counts SignalCards.updateCounts(output); - // Limit output to 50 cards - const cards = output.querySelectorAll('.signal-card'); + // Limit to max 50 unique meters (cards won't pile up since we update in place) + const cards = output.querySelectorAll('.signal-card.meter-aggregated'); 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('deviceCount').textContent = '0'; + // Clear meter aggregator data + if (typeof MeterAggregator !== 'undefined') { + MeterAggregator.clear(); + } + // Reset recon data deviceDatabase.clear(); newDeviceAlerts = 0;