diff --git a/static/css/components/signal-cards.css b/static/css/components/signal-cards.css index 5873680..c411529 100644 --- a/static/css/components/signal-cards.css +++ b/static/css/components/signal-cards.css @@ -851,3 +851,288 @@ margin-top: 8px; padding-top: 8px; } + +/* ============================================ + SEARCH INPUT + ============================================ */ +.signal-search-container { + flex: 1; + min-width: 150px; + max-width: 250px; +} + +.signal-search-input { + width: 100%; + padding: 6px 10px; + font-family: 'Inter', sans-serif; + font-size: 11px; + color: var(--text-primary); + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 4px; + outline: none; + transition: all 0.15s; +} + +.signal-search-input::placeholder { + color: var(--text-dim); +} + +.signal-search-input:focus { + border-color: var(--accent-cyan); + background: var(--bg-elevated); +} + +/* ============================================ + SEEN COUNT BADGE + ============================================ */ +.signal-seen-count { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--text-dim); + background: var(--bg-secondary); + padding: 2px 5px; + border-radius: 3px; +} + +/* ============================================ + SENSOR DATA DISPLAY + ============================================ */ +.signal-sensor-data { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding: 10px; + background: var(--bg-secondary); + border-radius: 4px; + border-left: 2px solid var(--accent-cyan); +} + +.signal-sensor-reading { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 70px; +} + +.signal-sensor-reading .sensor-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); +} + +.signal-sensor-reading .sensor-value { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); +} + +.signal-sensor-reading .sensor-value.low-battery { + color: var(--accent-red); +} + +/* Sensor protocol badge */ +.signal-proto-badge.sensor { + background: var(--accent-green-dim); + color: var(--accent-green); + border-color: rgba(34, 197, 94, 0.25); +} + +/* ============================================ + METER DATA DISPLAY + ============================================ */ +.signal-meter-data { + display: flex; + flex-wrap: wrap; + gap: 12px; + padding: 12px; + background: var(--bg-secondary); + border-radius: 4px; + border-left: 2px solid var(--accent-yellow); +} + +.signal-meter-reading { + display: flex; + flex-direction: column; + gap: 3px; +} + +.signal-meter-reading .meter-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-dim); +} + +.signal-meter-reading .meter-value { + font-family: 'JetBrains Mono', monospace; + font-size: 18px; + font-weight: 600; + color: var(--text-primary); +} + +/* Meter protocol badges */ +.signal-proto-badge.meter { + background: rgba(234, 179, 8, 0.15); + color: #eab308; + border-color: rgba(234, 179, 8, 0.25); +} + +.signal-proto-badge.meter.electric { + background: rgba(234, 179, 8, 0.15); + color: #eab308; + border-color: rgba(234, 179, 8, 0.25); +} + +.signal-proto-badge.meter.gas { + background: rgba(249, 115, 22, 0.15); + color: #f97316; + border-color: rgba(249, 115, 22, 0.25); +} + +.signal-proto-badge.meter.water { + background: rgba(59, 130, 246, 0.15); + color: #3b82f6; + border-color: rgba(59, 130, 246, 0.25); +} + +/* ============================================ + APRS SYMBOL + ============================================ */ +.signal-aprs-symbol { + font-size: 12px; + padding: 2px 4px; + background: var(--bg-secondary); + border-radius: 3px; +} + +/* ============================================ + DISTANCE DISPLAY + ============================================ */ +.signal-distance { + font-family: 'JetBrains Mono', monospace; + font-size: 10px; + color: var(--accent-green); + font-weight: 500; +} + +/* ============================================ + COMPACT CARD VARIANT + For constrained layouts like APRS station list + ============================================ */ +.signal-card-compact { + padding: 10px 12px; +} + +.signal-card-compact .signal-card-header { + margin-bottom: 6px; +} + +.signal-card-compact .signal-proto-badge { + font-size: 9px; + padding: 2px 5px; +} + +.signal-card-compact .signal-freq-badge { + font-size: 10px; + padding: 2px 6px; +} + +.signal-card-compact .signal-status-pill { + font-size: 9px; + padding: 2px 6px; +} + +.signal-card-compact .signal-message { + font-size: 11px; + padding: 6px 8px; + max-height: 40px; +} + +.signal-card-compact .signal-meta-row { + font-size: 9px; +} + +.signal-card-compact .signal-mini-map { + padding: 6px 8px; + font-size: 10px; +} + +.signal-card-compact .signal-card-footer { + margin-top: 6px; + padding-top: 6px; +} + +.signal-card-compact .signal-advanced-toggle { + font-size: 9px; + padding: 3px 6px; +} + +/* Compact filter bar for APRS */ +.signal-filter-bar-compact { + padding: 6px 8px; + margin-bottom: 8px; + gap: 4px; +} + +.signal-filter-bar-compact .signal-filter-btn { + padding: 3px 6px; + font-size: 9px; +} + +.signal-filter-bar-compact .signal-filter-count { + font-size: 8px; + padding: 1px 3px; + min-width: 14px; +} + +.signal-filter-bar-compact .signal-search-input { + padding: 4px 8px; + font-size: 10px; +} + +.signal-filter-bar-compact .signal-filter-divider { + margin: 0 4px; +} + +/* ============================================ + TONE ONLY MESSAGE STYLING + ============================================ */ +.signal-message.tone-only { + color: var(--text-dim); + font-style: italic; + border-left-color: var(--border-color); +} + +/* ============================================ + FILTER BAR RESPONSIVE + ============================================ */ +@media (max-width: 768px) { + .signal-filter-bar { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .signal-filter-bar > .signal-filter-label { + margin-top: 8px; + } + + .signal-filter-bar > .signal-filter-label:first-child { + margin-top: 0; + } + + .signal-filter-divider { + display: none; + } + + .signal-search-container { + max-width: none; + } + + .signal-filter-bar .signal-filter-btn { + flex: 1; + } +} diff --git a/static/js/components/signal-cards.js b/static/js/components/signal-cards.js index 107d5fe..499af0a 100644 --- a/static/js/components/signal-cards.js +++ b/static/js/components/signal-cards.js @@ -7,12 +7,31 @@ const SignalCards = (function() { 'use strict'; + // Address tracking for new/repeated detection + const addressHistory = { + pager: new Map(), // address -> { count, firstSeen, lastSeen } + aprs: new Map(), // callsign -> { count, firstSeen, lastSeen } + sensor: new Map(), // id -> { count, firstSeen, lastSeen } + acars: new Map(), // flight -> { count, firstSeen, lastSeen } + ais: new Map(), // mmsi -> { count, firstSeen, lastSeen } + meter: new Map() // meter id -> { count, firstSeen, lastSeen } + }; + + // Threshold for "repeated" status (messages from same source) + const REPEATED_THRESHOLD = 3; + + // Time window for "burst" detection (ms) + const BURST_WINDOW = 60000; // 1 minute + const BURST_THRESHOLD = 5; // 5+ messages in window = burst + // Store for managing cards and state const state = { cards: new Map(), filters: { status: 'all', - type: 'all' + protocol: 'all', + msgType: 'all', + search: '' }, counts: { all: 0, @@ -50,27 +69,123 @@ const SignalCards = (function() { } /** - * Determine signal status based on message data + * Track an address/identifier and return its status */ - function determineStatus(msg) { - // Check for emergency indicators + function trackAddress(type, identifier) { + const history = addressHistory[type]; + if (!history) return { isNew: true, count: 1 }; + + const now = Date.now(); + const existing = history.get(identifier); + + if (!existing) { + // First time seeing this address + history.set(identifier, { + count: 1, + firstSeen: now, + lastSeen: now, + recentTimestamps: [now] + }); + return { isNew: true, count: 1, isBurst: false }; + } + + // Update existing record + existing.count++; + existing.lastSeen = now; + + // Track recent timestamps for burst detection + existing.recentTimestamps = existing.recentTimestamps || []; + existing.recentTimestamps.push(now); + + // Clean old timestamps outside burst window + existing.recentTimestamps = existing.recentTimestamps.filter( + ts => (now - ts) < BURST_WINDOW + ); + + const isBurst = existing.recentTimestamps.length >= BURST_THRESHOLD; + const isRepeated = existing.count >= REPEATED_THRESHOLD; + + return { + isNew: false, + count: existing.count, + isBurst: isBurst, + isRepeated: isRepeated, + firstSeen: existing.firstSeen + }; + } + + /** + * Get address stats without updating + */ + function getAddressStats(type, identifier) { + const history = addressHistory[type]; + if (!history) return null; + return history.get(identifier) || null; + } + + /** + * Clear address history (e.g., on session reset) + */ + function clearAddressHistory(type) { + if (type) { + if (addressHistory[type]) { + addressHistory[type].clear(); + } + } else { + Object.keys(addressHistory).forEach(key => { + addressHistory[key].clear(); + }); + } + } + + /** + * Determine signal status based on message data and tracking + */ + function determineStatus(msg, trackingType = 'pager') { + // Check for emergency indicators first if (msg.emergency || - (msg.message && /emergency|distress|mayday|sos/i.test(msg.message))) { + (msg.message && /emergency|distress|mayday|sos|911|help/i.test(msg.message))) { return 'emergency'; } - // Check if it's a new/first-seen signal - if (msg.isNew || msg.firstSeen) { + + // Get identifier based on message type + let identifier; + switch (trackingType) { + case 'pager': + identifier = msg.address; + break; + case 'aprs': + identifier = msg.callsign || msg.source; + break; + case 'sensor': + identifier = msg.id || msg.sensor_id; + break; + case 'acars': + identifier = msg.flight || msg.tail; + break; + case 'ais': + identifier = msg.mmsi; + break; + default: + identifier = msg.address || msg.id; + } + + if (!identifier) { + return 'baseline'; + } + + // Track and get status + const stats = trackAddress(trackingType, identifier); + + if (stats.isNew) { return 'new'; } - // Check for burst activity - if (msg.burst || msg.spike) { + if (stats.isBurst) { return 'burst'; } - // Check for repeated pattern - if (msg.repeated || msg.count > 5) { + if (stats.isRepeated) { return 'repeated'; } - // Default to baseline return 'baseline'; } @@ -96,24 +211,39 @@ const SignalCards = (function() { return /^[0-9\s\-\*\#U]+$/.test(message); } + /** + * Get message type label + */ + function getMsgTypeLabel(msg) { + if (msg.msg_type) return msg.msg_type; + if (msg.message === '[Tone Only]') return 'Tone'; + if (isNumericContent(msg.message)) return 'Numeric'; + return 'Alpha'; + } + /** * Create a pager message card */ function createPagerCard(msg, options = {}) { - const status = options.status || determineStatus(msg); + const status = options.status || determineStatus(msg, 'pager'); const protoClass = getProtoClass(msg.protocol); const isNumeric = isNumericContent(msg.message); const relativeTime = formatRelativeTime(msg.timestamp); const isToneOnly = msg.message === '[Tone Only]' || msg.msg_type === 'Tone'; + const msgType = getMsgTypeLabel(msg); const card = document.createElement('article'); card.className = 'signal-card'; card.dataset.status = status; card.dataset.type = 'message'; card.dataset.protocol = protoClass; + card.dataset.msgType = msgType.toLowerCase(); if (msg.address) card.dataset.address = msg.address; - // Build card HTML + // Get address stats for display + const stats = getAddressStats('pager', msg.address); + const seenCount = stats ? stats.count : 1; + card.innerHTML = `
@@ -129,7 +259,8 @@ const SignalCards = (function() {
- ${msg.msg_type ? `${escapeHtml(msg.msg_type)}` : ''} + ${escapeHtml(msgType)} + ${seenCount > 1 ? `×${seenCount}` : ''} ${escapeHtml(relativeTime)}
${escapeHtml(msg.message || '[No content]')}
@@ -166,12 +297,14 @@ const SignalCards = (function() { ${escapeHtml(msg.function)}
` : ''} - ${msg.msg_type ? `
Type - ${escapeHtml(msg.msg_type)} + ${escapeHtml(msgType)} +
+
+ Seen + ${seenCount} time${seenCount > 1 ? 's' : ''}
- ` : ''}
Timestamp ${escapeHtml(msg.timestamp)} @@ -192,6 +325,501 @@ const SignalCards = (function() { return card; } + /** + * Create an APRS message card + */ + function createAprsCard(msg, options = {}) { + const status = options.status || determineStatus(msg, 'aprs'); + const relativeTime = formatRelativeTime(msg.timestamp); + const hasPosition = msg.latitude && msg.longitude; + + const card = document.createElement('article'); + card.className = 'signal-card'; + if (options.compact) card.classList.add('signal-card-compact'); + card.dataset.status = status; + card.dataset.type = 'aprs'; + card.dataset.protocol = 'aprs'; + if (msg.callsign) card.dataset.callsign = msg.callsign; + + // Determine APRS message type from packet_type or message content + let aprsType = msg.packet_type || 'position'; + if (msg.weather) aprsType = 'weather'; + else if (msg.telemetry) aprsType = 'telemetry'; + else if (msg.message) aprsType = 'message'; + else if (msg.status) aprsType = 'status'; + card.dataset.packetType = aprsType.toLowerCase(); + + // Get stats + const stats = getAddressStats('aprs', msg.callsign); + const seenCount = stats ? stats.count : 1; + + card.innerHTML = ` +
+
+ APRS + ${escapeHtml(msg.callsign || 'Unknown')} +
+ ${status !== 'baseline' ? ` + + + ${status.charAt(0).toUpperCase() + status.slice(1)} + + ` : ''} +
+
+
+ ${aprsType.charAt(0).toUpperCase() + aprsType.slice(1)} + ${msg.symbol ? `${escapeHtml(msg.symbol)}` : ''} + ${msg.distance !== null && msg.distance !== undefined ? `${msg.distance.toFixed(1)} mi` : ''} + ${seenCount > 1 ? `×${seenCount}` : ''} + ${escapeHtml(relativeTime)} +
+ ${msg.comment || msg.status || msg.message ? ` +
${escapeHtml(msg.comment || msg.status || msg.message)}
+ ` : ''} + ${msg.weather ? ` +
+ ${msg.weather.temp ? `Temp: ${msg.weather.temp}°F ` : ''} + ${msg.weather.humidity ? `Humidity: ${msg.weather.humidity}% ` : ''} + ${msg.weather.wind_speed ? `Wind: ${msg.weather.wind_speed}mph ` : ''} + ${msg.weather.wind_dir ? `from ${msg.weather.wind_dir}° ` : ''} +
+ ` : ''} + ${hasPosition ? ` +
+
+ ${msg.latitude.toFixed(4)}°, ${msg.longitude.toFixed(4)}° +
+ ` : ''} +
+ +
+
+
+
+
Station Details
+
+
+ Callsign + ${escapeHtml(msg.callsign)} +
+ ${msg.path ? ` +
+ Path + ${escapeHtml(msg.path)} +
+ ` : ''} + ${hasPosition ? ` +
+ Position + ${msg.latitude.toFixed(5)}°, ${msg.longitude.toFixed(5)}° +
+ ` : ''} + ${msg.altitude ? ` +
+ Altitude + ${msg.altitude} ft +
+ ` : ''} + ${msg.speed ? ` +
+ Speed + ${msg.speed} mph +
+ ` : ''} + ${msg.course ? ` +
+ Course + ${msg.course}° +
+ ` : ''} +
+ Seen + ${seenCount} time${seenCount > 1 ? 's' : ''} +
+
+
+ ${msg.raw ? ` +
+
Raw Packet
+
${escapeHtml(msg.raw)}
+
+ ` : ''} +
+
+
+ `; + + return card; + } + + /** + * Create a sensor (433MHz) message card + */ + function createSensorCard(msg, options = {}) { + const status = options.status || determineStatus(msg, 'sensor'); + const relativeTime = formatRelativeTime(msg.timestamp); + + const card = document.createElement('article'); + card.className = 'signal-card'; + card.dataset.status = status; + card.dataset.type = 'sensor'; + card.dataset.protocol = msg.model || 'unknown'; + if (msg.id) card.dataset.sensorId = msg.id; + + // Get stats + const stats = getAddressStats('sensor', msg.id); + const seenCount = stats ? stats.count : 1; + + // Determine sensor type icon + let sensorIcon = '📡'; + const model = (msg.model || '').toLowerCase(); + if (model.includes('weather') || msg.temperature !== undefined) sensorIcon = '🌡️'; + else if (model.includes('door') || model.includes('window')) sensorIcon = '🚪'; + else if (model.includes('motion') || model.includes('pir')) sensorIcon = '🚶'; + else if (model.includes('smoke') || model.includes('fire')) sensorIcon = '🔥'; + else if (model.includes('water') || model.includes('leak')) sensorIcon = '💧'; + else if (model.includes('car') || model.includes('tire') || model.includes('tpms')) sensorIcon = '🚗'; + + card.innerHTML = ` +
+
+ ${sensorIcon} ${escapeHtml(msg.model || 'Unknown')} + ID: ${escapeHtml(msg.id || 'N/A')} +
+ ${status !== 'baseline' ? ` + + + ${status.charAt(0).toUpperCase() + status.slice(1)} + + ` : ''} +
+
+
+ ${msg.channel ? `Ch ${msg.channel}` : ''} + ${seenCount > 1 ? `×${seenCount}` : ''} + ${escapeHtml(relativeTime)} +
+
+ ${msg.temperature !== undefined ? ` +
+ Temp + ${msg.temperature}°${msg.temperature_unit || 'F'} +
+ ` : ''} + ${msg.humidity !== undefined ? ` +
+ Humidity + ${msg.humidity}% +
+ ` : ''} + ${msg.battery !== undefined ? ` +
+ Battery + ${msg.battery} +
+ ` : ''} + ${msg.pressure !== undefined ? ` +
+ Pressure + ${msg.pressure} ${msg.pressure_unit || 'hPa'} +
+ ` : ''} + ${msg.wind_speed !== undefined ? ` +
+ Wind + ${msg.wind_speed} ${msg.wind_unit || 'mph'} +
+ ` : ''} + ${msg.rain !== undefined ? ` +
+ Rain + ${msg.rain} ${msg.rain_unit || 'mm'} +
+ ` : ''} + ${msg.state !== undefined ? ` +
+ State + ${escapeHtml(msg.state)} +
+ ` : ''} +
+
+ +
+
+
+
+
Sensor Details
+
+
+ Model + ${escapeHtml(msg.model || 'Unknown')} +
+
+ ID + ${escapeHtml(msg.id || 'N/A')} +
+ ${msg.channel ? ` +
+ Channel + ${msg.channel} +
+ ` : ''} + ${msg.frequency ? ` +
+ Frequency + ${msg.frequency} MHz +
+ ` : ''} +
+ Seen + ${seenCount} time${seenCount > 1 ? 's' : ''} +
+
+
+ ${msg.raw ? ` +
+
Raw Data
+
${escapeHtml(typeof msg.raw === 'object' ? JSON.stringify(msg.raw, null, 2) : msg.raw)}
+
+ ` : ''} +
+
+
+ `; + + return card; + } + + /** + * Create an ACARS message card + */ + function createAcarsCard(msg, options = {}) { + const status = options.status || determineStatus(msg, 'acars'); + const relativeTime = formatRelativeTime(msg.timestamp); + + const card = document.createElement('article'); + card.className = 'signal-card'; + card.dataset.status = status; + card.dataset.type = 'acars'; + card.dataset.protocol = 'acars'; + if (msg.flight) card.dataset.flight = msg.flight; + + // Get stats + const stats = getAddressStats('acars', msg.flight || msg.tail); + const seenCount = stats ? stats.count : 1; + + card.innerHTML = ` +
+
+ ACARS + ${escapeHtml(msg.flight || msg.tail || 'Unknown')} +
+ ${status !== 'baseline' ? ` + + + ${status.charAt(0).toUpperCase() + status.slice(1)} + + ` : ''} +
+
+
+ ${msg.label ? `${escapeHtml(msg.label)}` : ''} + ${seenCount > 1 ? `×${seenCount}` : ''} + ${escapeHtml(relativeTime)} +
+ ${msg.text ? ` +
${escapeHtml(msg.text)}
+ ` : ''} +
+ +
+
+
+
+
Flight Details
+
+ ${msg.flight ? ` +
+ Flight + ${escapeHtml(msg.flight)} +
+ ` : ''} + ${msg.tail ? ` +
+ Tail # + ${escapeHtml(msg.tail)} +
+ ` : ''} + ${msg.label ? ` +
+ Label + ${escapeHtml(msg.label)} +
+ ` : ''} + ${msg.mode ? ` +
+ Mode + ${escapeHtml(msg.mode)} +
+ ` : ''} + ${msg.frequency ? ` +
+ Frequency + ${msg.frequency} MHz +
+ ` : ''} +
+
+
+
+
+ `; + + return card; + } + + /** + * Create a utility meter (rtlamr) card + */ + function createMeterCard(msg, options = {}) { + const status = options.status || determineStatus(msg, 'meter'); + const relativeTime = formatRelativeTime(msg.timestamp); + + const card = document.createElement('article'); + card.className = 'signal-card'; + card.dataset.status = status; + card.dataset.type = 'meter'; + card.dataset.protocol = msg.type || 'unknown'; + if (msg.id) card.dataset.meterId = msg.id; + + // Get stats + const stats = getAddressStats('meter', msg.id); + const seenCount = stats ? stats.count : 1; + + // Determine meter type icon and color + let meterIcon = '⚡'; + let meterTypeClass = 'electric'; + const meterType = (msg.type || '').toLowerCase(); + if (meterType.includes('gas')) { + meterIcon = '🔥'; + meterTypeClass = 'gas'; + } else if (meterType.includes('water')) { + meterIcon = '💧'; + meterTypeClass = 'water'; + } + + card.innerHTML = ` +
+
+ ${meterIcon} ${escapeHtml(msg.type || 'Meter')} + ID: ${escapeHtml(msg.id || 'N/A')} +
+ ${status !== 'baseline' ? ` + + + ${status.charAt(0).toUpperCase() + status.slice(1)} + + ` : ''} +
+
+
+ ${msg.endpoint_type ? `${escapeHtml(msg.endpoint_type)}` : ''} + ${seenCount > 1 ? `×${seenCount}` : ''} + ${escapeHtml(relativeTime)} +
+
+ ${msg.consumption !== undefined ? ` +
+ Consumption + ${msg.consumption.toLocaleString()} ${msg.unit || 'units'} +
+ ` : ''} +
+
+ +
+
+
+
+
Meter Details
+
+
+ Meter ID + ${escapeHtml(msg.id || 'N/A')} +
+
+ Type + ${escapeHtml(msg.type || 'Unknown')} +
+ ${msg.endpoint_type ? ` +
+ Endpoint + ${escapeHtml(msg.endpoint_type)} +
+ ` : ''} + ${msg.endpoint_id ? ` +
+ Endpoint ID + ${escapeHtml(msg.endpoint_id)} +
+ ` : ''} +
+ Seen + ${seenCount} time${seenCount > 1 ? 's' : ''} +
+
+
+
+
+
+ `; + + return card; + } + /** * Toggle advanced panel on a card */ @@ -221,7 +849,6 @@ const SignalCards = (function() { * Mute an address (add to filter list) */ function muteAddress(address) { - // Store muted addresses in localStorage const muted = JSON.parse(localStorage.getItem('mutedAddresses') || '[]'); if (!muted.includes(address)) { muted.push(address); @@ -229,7 +856,7 @@ const SignalCards = (function() { showToast(`Address ${address} muted`); // Hide existing cards with this address - document.querySelectorAll(`.signal-card[data-address="${address}"]`).forEach(card => { + document.querySelectorAll(`.signal-card[data-address="${address}"], .signal-card[data-callsign="${address}"], .signal-card[data-sensor-id="${address}"]`).forEach(card => { card.style.opacity = '0'; card.style.transform = 'scale(0.95)'; setTimeout(() => card.remove(), 200); @@ -245,6 +872,18 @@ const SignalCards = (function() { return muted.includes(address); } + /** + * Show location on map (for APRS) + */ + function showOnMap(lat, lon, label) { + // Trigger custom event that map components can listen to + const event = new CustomEvent('showOnMap', { + detail: { lat, lon, label } + }); + document.dispatchEvent(event); + showToast(`Showing ${label} on map`); + } + /** * Show toast notification */ @@ -259,9 +898,7 @@ const SignalCards = (function() { toast.textContent = message; toast.className = 'signal-toast ' + type; - - // Force reflow for animation - toast.offsetHeight; + toast.offsetHeight; // Force reflow toast.classList.add('show'); setTimeout(() => { @@ -270,70 +907,148 @@ const SignalCards = (function() { } /** - * Initialize filter bar + * Create pager filter bar with protocol and message type filters */ - function initFilterBar(container, options = {}) { + function createPagerFilterBar(outputContainer, options = {}) { const filterBar = document.createElement('div'); filterBar.className = 'signal-filter-bar'; + filterBar.id = 'pagerFilterBar'; + filterBar.innerHTML = ` - Filter - - ${options.showEmergency !== false ? ` - - ` : ''} - - - + + + + Protocol + + + + + + + Type + + + + + + + +
+ +
`; - // Add click handlers - filterBar.querySelectorAll('.signal-filter-btn').forEach(btn => { + // Add click handlers for filter buttons + filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => { btn.addEventListener('click', () => { - filterBar.querySelectorAll('.signal-filter-btn').forEach(b => b.classList.remove('active')); + filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); - state.filters.status = btn.dataset.filter; - applyFilters(container); + state.filters.status = btn.dataset.value; + applyAllFilters(outputContainer); }); }); + filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(btn => { + btn.addEventListener('click', () => { + filterBar.querySelectorAll('.signal-filter-btn[data-filter="protocol"]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.filters.protocol = btn.dataset.value; + applyAllFilters(outputContainer); + }); + }); + + filterBar.querySelectorAll('.signal-filter-btn[data-filter="msgType"]').forEach(btn => { + btn.addEventListener('click', () => { + filterBar.querySelectorAll('.signal-filter-btn[data-filter="msgType"]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + state.filters.msgType = btn.dataset.value; + applyAllFilters(outputContainer); + }); + }); + + // Add search handler with debounce + const searchInput = filterBar.querySelector('#pagerSearchInput'); + let searchTimeout; + searchInput.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + state.filters.search = e.target.value.toLowerCase(); + applyAllFilters(outputContainer); + }, 200); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', (e) => { + // Only when not typing in an input + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + + if (e.key === '/') { + e.preventDefault(); + searchInput.focus(); + } + }); + return filterBar; } /** - * Apply current filters to cards + * Apply all filters (status, protocol, msgType, search) */ - function applyFilters(container) { + function applyAllFilters(container) { const cards = container.querySelectorAll('.signal-card'); let visibleCount = 0; + const counts = { + all: 0, + new: 0, + repeated: 0, + burst: 0, + baseline: 0, + emergency: 0 + }; cards.forEach(card => { const cardStatus = card.dataset.status; - const cardType = card.dataset.type; + const cardProtocol = card.dataset.protocol; + const cardMsgType = card.dataset.msgType; + const cardAddress = card.dataset.address || ''; + const cardContent = card.querySelector('.signal-message')?.textContent || ''; + // Count all cards by status + counts.all++; + if (counts.hasOwnProperty(cardStatus)) { + counts[cardStatus]++; + } + + // Check all filters const statusMatch = state.filters.status === 'all' || cardStatus === state.filters.status; - const typeMatch = state.filters.type === 'all' || cardType === state.filters.type; + const protocolMatch = state.filters.protocol === 'all' || cardProtocol === state.filters.protocol; + const typeMatch = state.filters.msgType === 'all' || cardMsgType === state.filters.msgType; + const searchMatch = !state.filters.search || + cardAddress.toLowerCase().includes(state.filters.search) || + cardContent.toLowerCase().includes(state.filters.search); - if (statusMatch && typeMatch) { + if (statusMatch && protocolMatch && typeMatch && searchMatch) { card.classList.remove('hidden'); visibleCount++; } else { @@ -341,45 +1056,259 @@ const SignalCards = (function() { } }); + // Update count badges + const filterBar = document.getElementById('pagerFilterBar'); + if (filterBar) { + Object.keys(counts).forEach(key => { + const badge = filterBar.querySelector(`[data-count="${key}"]`); + if (badge) { + badge.textContent = counts[key]; + } + }); + } + // Show/hide empty state const emptyState = container.querySelector('.signal-empty-state'); if (emptyState) { - emptyState.style.display = visibleCount === 0 ? 'block' : 'none'; + emptyState.style.display = visibleCount === 0 && cards.length > 0 ? 'block' : 'none'; } + + state.counts = counts; + } + + /** + * Initialize filter bar (legacy support) + */ + function initFilterBar(container, options = {}) { + return createPagerFilterBar(container, options); + } + + /** + * Apply current filters to cards (legacy support) + */ + function applyFilters(container) { + applyAllFilters(container); } /** * Update filter counts */ function updateCounts(container) { - const cards = container.querySelectorAll('.signal-card'); - const counts = { - all: 0, - emergency: 0, - new: 0, - burst: 0, - repeated: 0, - baseline: 0 + applyAllFilters(container); + return state.counts; + } + + /** + * Create APRS filter bar with status and packet type filters + */ + function createAprsFilterBar(outputContainer, options = {}) { + const filterBar = document.createElement('div'); + filterBar.className = 'signal-filter-bar signal-filter-bar-compact'; + filterBar.id = 'aprsFilterBar'; + + filterBar.innerHTML = ` + + + + + + + Type + + + + + +
+ +
+ `; + + // Store filter state specific to APRS + const aprsFilters = { status: 'all', packetType: 'all', search: '' }; + + // Apply filters function for APRS + const applyAprsFilters = () => { + const cards = outputContainer.querySelectorAll('.signal-card'); + let visibleCount = 0; + const counts = { all: 0, new: 0, repeated: 0, burst: 0, baseline: 0, emergency: 0 }; + + cards.forEach(card => { + const cardStatus = card.dataset.status; + const cardType = card.dataset.packetType || card.querySelector('.signal-msg-type')?.textContent?.toLowerCase() || ''; + const cardCallsign = card.dataset.callsign || ''; + + counts.all++; + if (counts.hasOwnProperty(cardStatus)) counts[cardStatus]++; + + const statusMatch = aprsFilters.status === 'all' || cardStatus === aprsFilters.status; + const typeMatch = aprsFilters.packetType === 'all' || cardType.includes(aprsFilters.packetType); + const searchMatch = !aprsFilters.search || cardCallsign.toLowerCase().includes(aprsFilters.search); + + if (statusMatch && typeMatch && searchMatch) { + card.classList.remove('hidden'); + visibleCount++; + } else { + card.classList.add('hidden'); + } + }); + + // Update count badges + Object.keys(counts).forEach(key => { + const badge = filterBar.querySelector(`[data-count="${key}"]`); + if (badge) badge.textContent = counts[key]; + }); }; - cards.forEach(card => { - counts.all++; - const status = card.dataset.status; - if (counts.hasOwnProperty(status)) { - counts[status]++; - } + // Status filter handlers + filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => { + btn.addEventListener('click', () => { + filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + aprsFilters.status = btn.dataset.value; + applyAprsFilters(); + }); }); - // Update count badges - Object.keys(counts).forEach(key => { - const badge = container.querySelector(`[data-count="${key}"]`); - if (badge) { - badge.textContent = counts[key]; - } + // Packet type filter handlers + filterBar.querySelectorAll('.signal-filter-btn[data-filter="packetType"]').forEach(btn => { + btn.addEventListener('click', () => { + filterBar.querySelectorAll('.signal-filter-btn[data-filter="packetType"]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + aprsFilters.packetType = btn.dataset.value; + applyAprsFilters(); + }); }); - state.counts = counts; - return counts; + // Search handler with debounce + const searchInput = filterBar.querySelector('#aprsSearchInput'); + let searchTimeout; + searchInput.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + aprsFilters.search = e.target.value.toLowerCase(); + applyAprsFilters(); + }, 200); + }); + + // Store applyFilters reference for external calls + filterBar.applyFilters = applyAprsFilters; + + return filterBar; + } + + /** + * Create Sensor (433MHz) filter bar + */ + function createSensorFilterBar(outputContainer, options = {}) { + const filterBar = document.createElement('div'); + filterBar.className = 'signal-filter-bar'; + filterBar.id = 'sensorFilterBar'; + + filterBar.innerHTML = ` + Status + + + + + + + +
+ +
+ `; + + // Store filter state for sensors + const sensorFilters = { status: 'all', search: '' }; + + // Apply filters function for sensors + const applySensorFilters = () => { + const cards = outputContainer.querySelectorAll('.signal-card'); + let visibleCount = 0; + const counts = { all: 0, new: 0, repeated: 0, burst: 0, baseline: 0, emergency: 0 }; + + cards.forEach(card => { + const cardStatus = card.dataset.status; + const cardProtocol = card.dataset.protocol || ''; + const cardSensorId = card.dataset.sensorId || ''; + + counts.all++; + if (counts.hasOwnProperty(cardStatus)) counts[cardStatus]++; + + const statusMatch = sensorFilters.status === 'all' || cardStatus === sensorFilters.status; + const searchMatch = !sensorFilters.search || + cardProtocol.toLowerCase().includes(sensorFilters.search) || + cardSensorId.toLowerCase().includes(sensorFilters.search); + + if (statusMatch && searchMatch) { + card.classList.remove('hidden'); + visibleCount++; + } else { + card.classList.add('hidden'); + } + }); + + // Update count badges + Object.keys(counts).forEach(key => { + const badge = filterBar.querySelector(`[data-count="${key}"]`); + if (badge) badge.textContent = counts[key]; + }); + }; + + // Status filter handlers + filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(btn => { + btn.addEventListener('click', () => { + filterBar.querySelectorAll('.signal-filter-btn[data-filter="status"]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + sensorFilters.status = btn.dataset.value; + applySensorFilters(); + }); + }); + + // Search handler with debounce + const searchInput = filterBar.querySelector('#sensorSearchInput'); + let searchTimeout; + searchInput.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + sensorFilters.search = e.target.value.toLowerCase(); + applySensorFilters(); + }, 200); + }); + + // Store applyFilters reference for external calls + filterBar.applyFilters = applySensorFilters; + + return filterBar; } /** @@ -396,21 +1325,45 @@ const SignalCards = (function() { // Public API return { + // Card creators createPagerCard, + createAprsCard, + createSensorCard, + createAcarsCard, + createMeterCard, + + // UI interactions toggleAdvanced, copyMessage, muteAddress, isAddressMuted, + showOnMap, showToast, + + // Filter bar + createPagerFilterBar, + createAprsFilterBar, + createSensorFilterBar, initFilterBar, applyFilters, + applyAllFilters, updateCounts, updateTimestamps, + + // Address tracking + trackAddress, + getAddressStats, + clearAddressHistory, + + // Utilities escapeHtml, formatRelativeTime, determineStatus, getProtoClass, - state + + // State + state, + addressHistory }; })(); diff --git a/templates/index.html b/templates/index.html index cf50d38..ddbf6ea 100644 --- a/templates/index.html +++ b/templates/index.html @@ -995,12 +995,13 @@
+ style="flex: 1; min-width: 300px; max-width: 400px; display: flex; flex-direction: column;">
STATION LIST
-
-
+
+
+
No stations received yet
@@ -1523,6 +1524,9 @@
+ + +
@@ -2172,6 +2176,22 @@ reserveDevice(parseInt(device), 'sensor'); setSensorRunning(true); startSensorStream(); + + // Initialize sensor filter bar + const filterContainer = document.getElementById('filterBarContainer'); + const output = document.getElementById('output'); + if (filterContainer) { + filterContainer.innerHTML = ''; + const filterBar = SignalCards.createSensorFilterBar(output); + filterContainer.appendChild(filterBar); + filterContainer.style.display = 'block'; + } + + // Clear address history for fresh session + SignalCards.clearAddressHistory('sensor'); + + // Clear existing output + output.innerHTML = ''; } else { alert('Error: ' + data.message); } @@ -2250,40 +2270,51 @@ document.getElementById('deviceCount').textContent = uniqueDevices.size; } - const card = document.createElement('div'); - card.className = 'sensor-card'; + // Convert rtl_433 data format to our card format + const msg = { + model: data.model || 'Unknown', + id: data.id || data.channel || 'N/A', + channel: data.channel, + timestamp: data.time || new Date().toISOString(), + raw: data.raw, + frequency: data.freq + }; - let dataItems = ''; - const skipKeys = ['type', 'time', 'model', 'raw']; - for (const [key, value] of Object.entries(data)) { - if (!skipKeys.includes(key) && value !== null && value !== undefined) { - const label = key.replace(/_/g, ' '); - let displayValue = value; - if (key === 'temperature_C') displayValue = value + ' °C'; - else if (key === 'temperature_F') displayValue = value + ' °F'; - else if (key === 'humidity') displayValue = value + ' %'; - else if (key === 'pressure_hPa') displayValue = value + ' hPa'; - else if (key === 'wind_avg_km_h') displayValue = value + ' km/h'; - else if (key === 'rain_mm') displayValue = value + ' mm'; - else if (key === 'battery_ok') displayValue = value ? 'OK' : 'Low'; - - dataItems += '
' + label + '
' + displayValue + '
'; - } + // Map common sensor fields + if (data.temperature_C !== undefined) { + msg.temperature = data.temperature_C; + msg.temperature_unit = 'C'; + } else if (data.temperature_F !== undefined) { + msg.temperature = data.temperature_F; + msg.temperature_unit = 'F'; + } + if (data.humidity !== undefined) msg.humidity = data.humidity; + if (data.battery_ok !== undefined) msg.battery = data.battery_ok ? 'OK' : 'LOW'; + if (data.pressure_hPa !== undefined) { + msg.pressure = data.pressure_hPa; + msg.pressure_unit = 'hPa'; + } + if (data.wind_avg_km_h !== undefined) { + msg.wind_speed = data.wind_avg_km_h; + msg.wind_unit = 'km/h'; + } + if (data.rain_mm !== undefined) { + msg.rain = data.rain_mm; + msg.rain_unit = 'mm'; } - const relTime = data.time ? getRelativeTime(data.time.split(' ')[1] || data.time) : 'now'; - - card.innerHTML = - '
' + - '' + (data.model || 'Unknown Device') + '' + - '' + relTime + '' + - '
' + - '
' + dataItems + '
'; - + // Create card using SignalCards component + const card = SignalCards.createSensorCard(msg); output.insertBefore(card, output.firstChild); + // Update filter counts + SignalCards.updateCounts(output); + if (autoScroll) output.scrollTop = 0; - while (output.children.length > 100) { + + // Keep list manageable + const cards = output.querySelectorAll('.signal-card'); + while (cards.length > 100) { output.removeChild(output.lastChild); } } @@ -2341,6 +2372,25 @@ reserveDevice(parseInt(device), 'rtlamr'); setRtlamrRunning(true); startRtlamrStream(); + + // Initialize meter filter bar (reuse sensor filter bar since same structure) + const filterContainer = document.getElementById('filterBarContainer'); + const output = document.getElementById('output'); + if (filterContainer) { + filterContainer.innerHTML = ''; + const filterBar = SignalCards.createSensorFilterBar(output); + filterBar.id = 'meterFilterBar'; + filterBar.querySelector('#sensorSearchInput').id = 'meterSearchInput'; + filterBar.querySelector('#meterSearchInput').placeholder = 'Search meter ID...'; + filterContainer.appendChild(filterBar); + filterContainer.style.display = 'block'; + } + + // Clear address history for fresh session + SignalCards.clearAddressHistory('meter'); + + // Clear existing output + output.innerHTML = ''; } else { alert('Error: ' + data.message); } @@ -2419,7 +2469,8 @@ document.getElementById('sensorCount').textContent = sensorCount; // Track unique meters by ID - const meterId = data.Message?.ID || 'Unknown'; + const msgData = data.Message || {}; + const meterId = msgData.ID || 'Unknown'; if (meterId !== 'Unknown') { const deviceKey = 'METER_' + meterId; if (!uniqueDevices.has(deviceKey)) { @@ -2428,35 +2479,27 @@ } } - const card = document.createElement('div'); - card.className = 'sensor-card'; - - let dataItems = ''; - const msg = data.Message || {}; - - // Build display from message data - for (const [key, value] of Object.entries(msg)) { - if (value !== null && value !== undefined) { - const label = key.replace(/_/g, ' '); - let displayValue = value; - if (key === 'Consumption') displayValue = value + ' units'; - dataItems += `
${label}: ${displayValue}
`; - } - } - - const timestamp = new Date().toLocaleTimeString(); - card.innerHTML = ` -
- ${data.Type || 'Meter'} - ${timestamp} -
-
${dataItems}
- `; + // Convert rtlamr data to our card format + const msg = { + id: String(meterId), + type: data.Type || 'Unknown', + consumption: msgData.Consumption, + unit: 'units', + endpoint_type: msgData.EndpointType, + endpoint_id: msgData.EndpointID, + timestamp: new Date().toISOString() + }; + // Create card using SignalCards component + const card = SignalCards.createMeterCard(msg); output.insertBefore(card, output.firstChild); + // Update filter counts + SignalCards.updateCounts(output); + // Limit output to 50 cards - while (output.children.length > 50) { + const cards = output.querySelectorAll('.signal-card'); + while (cards.length > 50) { output.removeChild(output.lastChild); } } @@ -2975,6 +3018,20 @@ reserveDevice(parseInt(device), 'pager'); setRunning(true); startStream(); + + // Initialize filter bar + const filterContainer = document.getElementById('filterBarContainer'); + const output = document.getElementById('output'); + if (filterContainer) { + // Clear any existing filter bar and create pager filter + filterContainer.innerHTML = ''; + const filterBar = SignalCards.createPagerFilterBar(output); + filterContainer.appendChild(filterBar); + filterContainer.style.display = 'block'; + } + + // Clear address history for fresh session + SignalCards.clearAddressHistory('pager'); } else { alert('Error: ' + data.message); } @@ -3140,21 +3197,23 @@ // Add to waterfall addWaterfallPoint(Date.now(), 0.8); - // Use SignalCards component to create the message card - const msgEl = SignalCards.createPagerCard(msg, { - status: 'baseline' // Default status, can be enhanced with detection logic - }); + // Use SignalCards component to create the message card (auto-detects status) + const msgEl = SignalCards.createPagerCard(msg); output.insertBefore(msgEl, output.firstChild); + // Update filter counts + SignalCards.updateCounts(output); + // Auto-scroll to top (newest messages) if (autoScroll) { output.scrollTop = 0; } - // Limit messages displayed - while (output.children.length > 100) { - output.removeChild(output.lastChild); + // Limit messages displayed (keep placeholder/empty-state) + const cards = output.querySelectorAll('.signal-card'); + while (cards.length > 100) { + output.removeChild(cards[cards.length - 1]); } } @@ -6741,6 +6800,19 @@ isAprsRunning = true; aprsPacketCount = 0; aprsStationCount = 0; + + // Initialize APRS filter bar and clear history + const filterContainer = document.getElementById('aprsFilterBarContainer'); + const stationList = document.getElementById('aprsStationList'); + if (filterContainer && !document.getElementById('aprsFilterBar')) { + const filterBar = SignalCards.createAprsFilterBar(stationList); + filterContainer.appendChild(filterBar); + } + SignalCards.clearAddressHistory('aprs'); + + // Clear existing station cards + stationList.innerHTML = '
Waiting for stations...
'; + // Update function bar buttons document.getElementById('aprsStripStartBtn').style.display = 'none'; document.getElementById('aprsStripStopBtn').style.display = 'inline-block'; @@ -7008,60 +7080,76 @@ const callsign = packet.callsign; // Remove placeholder if present - const placeholder = listEl.querySelector('div[style*="text-align: center"]'); - if (placeholder && placeholder.textContent.includes('No stations')) { + const placeholder = listEl.querySelector('.signal-cards-placeholder'); + if (placeholder) { placeholder.remove(); } + // Calculate distance if user location available + let distance = null; + const hasPos = packet.lat && packet.lon; + if (hasPos && aprsUserLocation.lat && aprsUserLocation.lon) { + distance = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon); + } + // Check if station already exists let stationEl = listEl.querySelector(`[data-callsign="${callsign}"]`); + const isExisting = !!stationEl; - if (!stationEl) { - stationEl = document.createElement('div'); - stationEl.dataset.callsign = callsign; - stationEl.style.cssText = 'padding: 6px 8px; border-bottom: 1px solid var(--border-color); cursor: pointer;'; - stationEl.onclick = () => { - if (aprsMarkers[callsign] && aprsMap) { - aprsMap.setView(aprsMarkers[callsign].getLatLng(), 10); - aprsMarkers[callsign].openPopup(); - } - }; - listEl.insertBefore(stationEl, listEl.firstChild); - } + // Prepare message object for card creation + const msg = { + callsign: callsign, + packet_type: packet.packet_type || 'unknown', + latitude: packet.lat, + longitude: packet.lon, + altitude: packet.altitude, + speed: packet.speed, + course: packet.course, + comment: packet.comment, + symbol: packet.symbol, + path: packet.path, + raw: packet.raw, + timestamp: new Date().toISOString(), + distance: distance + }; - const time = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' }); - const hasPos = packet.lat && packet.lon; + // Create or update the card + const newCard = SignalCards.createAprsCard(msg, { compact: true }); + newCard.dataset.callsign = callsign; - // Store lat/lon in dataset for distance updates + // Store position for distance updates if (hasPos) { - stationEl.dataset.lat = packet.lat; - stationEl.dataset.lon = packet.lon; + newCard.dataset.lat = packet.lat; + newCard.dataset.lon = packet.lon; } - // Calculate distance if user location available - let distStr = ''; - if (hasPos && aprsUserLocation.lat && aprsUserLocation.lon) { - const dist = aprsCalculateDistanceMi(aprsUserLocation.lat, aprsUserLocation.lon, packet.lat, packet.lon); - distStr = `${dist.toFixed(1)} mi`; - } else if (hasPos) { - distStr = `-- mi`; - } + // Add click handler to focus map + newCard.style.cursor = 'pointer'; + newCard.addEventListener('click', (e) => { + // Don't trigger if clicking on buttons + if (e.target.closest('button')) return; + if (aprsMarkers[callsign] && aprsMap) { + aprsMap.setView(aprsMarkers[callsign].getLatLng(), 10); + aprsMarkers[callsign].openPopup(); + } + }); - stationEl.innerHTML = ` -
- ${callsign} - ${time} -
-
- ${packet.packet_type || 'unknown'} ${hasPos ? `| ${packet.lat.toFixed(2)}, ${packet.lon.toFixed(2)}` : ''} - ${distStr} -
- `; + if (isExisting) { + // Replace existing card + stationEl.replaceWith(newCard); + } else { + // Insert new card at top + listEl.insertBefore(newCard, listEl.firstChild); + } // Keep list manageable - while (listEl.children.length > 50) { + const cards = listEl.querySelectorAll('.signal-card'); + while (cards.length > 50) { listEl.removeChild(listEl.lastChild); } + + // Update filter counts if filter bar exists + SignalCards.updateCounts(listEl); }