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 `
+
+ `;
+ }
+
+ /**
+ * 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 `
+
+ `;
+ }
+
+ /**
+ * Create empty sparkline placeholder
+ */
+ function createEmptySparkline(width, height) {
+ return `
+
+ `;
+ }
+
+ /**
+ * 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