From cd168da7606a5705fb6485d3782ee7e85e5402ec Mon Sep 17 00:00:00 2001 From: Smittix Date: Thu, 15 Jan 2026 22:03:08 +0000 Subject: [PATCH] Add graphical signal meter for APRS decoding Backend changes (routes/aprs.py): - Remove -q h flag from direwolf to enable audio level output - Add parse_audio_level() to extract levels from direwolf output - Add rate-limiting (max 10 updates/sec, min 2-level change) - Push meter events to SSE queue as type='meter' Frontend changes: - Add signal meter widget to APRS sidebar - Horizontal bar gauge with gradient (green->cyan->yellow->red) - Numeric level display (0-100) - "BURST" indicator for levels >70 - Status text (weak/moderate/strong signal) - "No RF activity" state after 5 seconds of silence - CSS styles in static/css/modes/aprs.css Also added UK region to dropdown (same freq as Europe: 144.800) Co-Authored-By: Claude Opus 4.5 --- routes/aprs.py | 75 ++++++++++++++++++++-- static/css/modes/aprs.css | 83 ++++++++++++++++++++++++ templates/index.html | 100 ++++++++++++++++++++++++++++- templates/partials/modes/aprs.html | 20 ++++++ 4 files changed, 273 insertions(+), 5 deletions(-) diff --git a/routes/aprs.py b/routes/aprs.py index e4c0f8b..ac99dbb 100644 --- a/routes/aprs.py +++ b/routes/aprs.py @@ -50,6 +50,12 @@ aprs_station_count = 0 aprs_last_packet_time = None aprs_stations = {} # callsign -> station data +# Meter rate limiting +_last_meter_time = 0.0 +_last_meter_level = -1 +METER_MIN_INTERVAL = 0.1 # Max 10 updates/sec +METER_MIN_CHANGE = 2 # Only send if level changes by at least this much + def find_direwolf() -> Optional[str]: """Find direwolf binary.""" @@ -275,14 +281,64 @@ def parse_weather(data: str) -> dict: return weather +def parse_audio_level(line: str) -> Optional[int]: + """Parse direwolf audio level line and return normalized level (0-100). + + Direwolf outputs lines like: + Audio level = 34(18/16) [NONE] __||||||______ + [0.4] Audio level = 57(34/32) [NONE] __||||||||||||______ + + The first number after "Audio level = " is the main level indicator. + We normalize it to 0-100 scale (direwolf typically outputs 0-100+). + """ + # Match "Audio level = NN" pattern + match = re.search(r'Audio level\s*=\s*(\d+)', line, re.IGNORECASE) + if match: + raw_level = int(match.group(1)) + # Normalize: direwolf levels are typically 0-100, but can go higher + # Clamp to 0-100 range + normalized = min(max(raw_level, 0), 100) + return normalized + return None + + +def should_send_meter_update(level: int) -> bool: + """Rate-limit meter updates to avoid spamming SSE. + + Only send if: + - At least METER_MIN_INTERVAL seconds have passed, OR + - Level changed by at least METER_MIN_CHANGE + """ + global _last_meter_time, _last_meter_level + + now = time.time() + time_ok = (now - _last_meter_time) >= METER_MIN_INTERVAL + change_ok = abs(level - _last_meter_level) >= METER_MIN_CHANGE + + if time_ok or change_ok: + _last_meter_time = now + _last_meter_level = level + return True + return False + + def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subprocess.Popen) -> None: - """Stream decoded APRS packets to queue. + """Stream decoded APRS packets and audio level meter to queue. This function reads from the decoder's stdout (text mode, line-buffered). The decoder's stderr is merged into stdout (STDOUT) to avoid deadlocks. rtl_fm's stderr is sent to DEVNULL for the same reason. + + Outputs two types of messages to the queue: + - type='aprs': Decoded APRS packets + - type='meter': Audio level meter readings (rate-limited) """ global aprs_packet_count, aprs_station_count, aprs_last_packet_time, aprs_stations + global _last_meter_time, _last_meter_level + + # Reset meter state + _last_meter_time = 0.0 + _last_meter_level = -1 try: app_module.aprs_queue.put({'type': 'status', 'status': 'started'}) @@ -293,6 +349,18 @@ def stream_aprs_output(rtl_process: subprocess.Popen, decoder_process: subproces if not line: continue + # Check for audio level line first (for signal meter) + audio_level = parse_audio_level(line) + if audio_level is not None: + if should_send_meter_update(audio_level): + meter_msg = { + 'type': 'meter', + 'level': audio_level, + 'ts': datetime.utcnow().isoformat() + 'Z' + } + app_module.aprs_queue.put(meter_msg) + continue # Audio level lines are not packets + # multimon-ng prefixes decoded packets with "AFSK1200: " if line.startswith('AFSK1200:'): line = line[9:].strip() @@ -489,7 +557,7 @@ def start_aprs() -> Response: # -r 22050 = sample rate (must match rtl_fm -s) # -b 16 = 16-bit signed samples # -t 0 = disable text colors (for cleaner parsing) - # -q h = quiet: suppress audio level heard line (keeps packet output) + # NOTE: We do NOT use -q h here so we get audio level lines for the signal meter # - = read audio from stdin (must be last argument) decoder_cmd = [ direwolf_path, @@ -498,7 +566,6 @@ def start_aprs() -> Response: '-r', '22050', '-b', '16', '-t', '0', - '-q', 'h', '-' ] decoder_name = 'direwolf' @@ -843,4 +910,4 @@ def scan_aprs_spectrum() -> Response: os.remove(tmp_file) except Exception: pass - + diff --git a/static/css/modes/aprs.css b/static/css/modes/aprs.css index 2b225b4..b87c07b 100644 --- a/static/css/modes/aprs.css +++ b/static/css/modes/aprs.css @@ -46,3 +46,86 @@ .aprs-stat-label { color: var(--text-muted); } + +/* Signal Meter Styles */ +.aprs-signal-meter { + margin-top: 12px; + padding: 10px; + background: rgba(0,0,0,0.3); + border: 1px solid var(--border-color); + border-radius: 4px; +} +.aprs-meter-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; +} +.aprs-meter-label { + font-size: 10px; + font-weight: bold; + letter-spacing: 1px; + color: var(--text-secondary); +} +.aprs-meter-value { + font-size: 12px; + font-weight: bold; + font-family: monospace; + color: var(--accent-cyan); + min-width: 24px; +} +.aprs-meter-burst { + font-size: 9px; + font-weight: bold; + color: var(--accent-yellow); + background: rgba(255, 193, 7, 0.2); + padding: 2px 6px; + border-radius: 3px; + animation: burst-flash 0.3s ease-out; +} +@keyframes burst-flash { + 0% { opacity: 1; transform: scale(1.1); } + 100% { opacity: 1; transform: scale(1); } +} +.aprs-meter-bar-container { + position: relative; + height: 16px; + background: rgba(0,0,0,0.4); + border-radius: 3px; + overflow: hidden; + margin-bottom: 4px; +} +.aprs-meter-bar { + height: 100%; + width: 0%; + background: linear-gradient(90deg, + var(--accent-green) 0%, + var(--accent-cyan) 50%, + var(--accent-yellow) 75%, + var(--accent-red) 100% + ); + border-radius: 3px; + transition: width 0.1s ease-out; +} +.aprs-meter-bar.no-signal { + opacity: 0.3; +} +.aprs-meter-ticks { + display: flex; + justify-content: space-between; + font-size: 8px; + color: var(--text-muted); + padding: 0 2px; +} +.aprs-meter-status { + font-size: 9px; + color: var(--text-muted); + text-align: center; + margin-top: 6px; +} +.aprs-meter-status.active { + color: var(--accent-green); +} +.aprs-meter-status.no-signal { + color: var(--accent-yellow); +} diff --git a/templates/index.html b/templates/index.html index 4ff69bb..b6df208 100644 --- a/templates/index.html +++ b/templates/index.html @@ -5949,6 +5949,9 @@ let isAprsRunning = false; let aprsPacketCount = 0; let aprsStationCount = 0; + let aprsMeterLastUpdate = 0; + let aprsMeterCheckInterval = null; + const APRS_METER_TIMEOUT = 5000; // 5 seconds for "no signal" state function checkAprsTools() { fetch('/aprs/tools') @@ -6052,6 +6055,10 @@ updateAprsStatus('listening', data.frequency); document.getElementById('aprsStatusStations').textContent = '0'; document.getElementById('aprsStatusPackets').textContent = '0'; + // Show signal meter + document.getElementById('aprsSignalMeter').style.display = 'block'; + resetAprsMeter(); + startAprsMeterCheck(); startAprsStream(); } else { alert('APRS Error: ' + data.message); @@ -6071,10 +6078,13 @@ isAprsRunning = false; document.getElementById('startAprsBtn').style.display = 'block'; document.getElementById('stopAprsBtn').style.display = 'none'; - // Hide sidebar status bar + // Hide sidebar status bar and signal meter document.getElementById('aprsStatusBar').style.display = 'none'; + document.getElementById('aprsSignalMeter').style.display = 'none'; document.getElementById('aprsMapStatus').textContent = 'STANDBY'; document.getElementById('aprsMapStatus').style.color = ''; + // Stop meter check interval + stopAprsMeterCheck(); if (aprsEventSource) { aprsEventSource.close(); aprsEventSource = null; @@ -6099,6 +6109,9 @@ updateAprsStatus('tracking'); } processAprsPacket(data); + } else if (data.type === 'meter') { + // Update signal meter + updateAprsMeter(data.level); } }; @@ -6108,6 +6121,91 @@ }; } + // Signal Meter Functions + function resetAprsMeter() { + aprsMeterLastUpdate = 0; + const bar = document.getElementById('aprsMeterBar'); + const value = document.getElementById('aprsMeterValue'); + const burst = document.getElementById('aprsMeterBurst'); + const status = document.getElementById('aprsMeterStatus'); + if (bar) { + bar.style.width = '0%'; + bar.classList.remove('no-signal'); + } + if (value) value.textContent = '--'; + if (burst) burst.style.display = 'none'; + if (status) { + status.textContent = 'Waiting for signal...'; + status.className = 'aprs-meter-status'; + } + } + + function updateAprsMeter(level) { + aprsMeterLastUpdate = Date.now(); + const bar = document.getElementById('aprsMeterBar'); + const value = document.getElementById('aprsMeterValue'); + const burst = document.getElementById('aprsMeterBurst'); + const status = document.getElementById('aprsMeterStatus'); + + if (bar) { + bar.style.width = level + '%'; + bar.classList.remove('no-signal'); + } + if (value) value.textContent = level; + + // Show burst indicator for high levels (>70) + if (burst) { + if (level > 70) { + burst.style.display = 'inline'; + // Remove and re-add to trigger animation + burst.style.animation = 'none'; + burst.offsetHeight; // Trigger reflow + burst.style.animation = null; + } else { + burst.style.display = 'none'; + } + } + + // Update status text + if (status) { + status.className = 'aprs-meter-status active'; + if (level < 10) { + status.textContent = 'Low signal / noise floor'; + } else if (level < 30) { + status.textContent = 'Weak signal detected'; + } else if (level < 60) { + status.textContent = 'Moderate signal'; + } else { + status.textContent = 'Strong signal / packet burst'; + } + } + } + + function startAprsMeterCheck() { + // Check for no-signal state every second + aprsMeterCheckInterval = setInterval(function() { + if (aprsMeterLastUpdate > 0 && (Date.now() - aprsMeterLastUpdate) > APRS_METER_TIMEOUT) { + // No meter updates for 5 seconds - show no-signal state + const bar = document.getElementById('aprsMeterBar'); + const status = document.getElementById('aprsMeterStatus'); + const burst = document.getElementById('aprsMeterBurst'); + if (bar) bar.classList.add('no-signal'); + if (burst) burst.style.display = 'none'; + if (status) { + status.textContent = 'No RF activity / silence'; + status.className = 'aprs-meter-status no-signal'; + } + } + }, 1000); + } + + function stopAprsMeterCheck() { + if (aprsMeterCheckInterval) { + clearInterval(aprsMeterCheckInterval); + aprsMeterCheckInterval = null; + } + } + function processAprsPacket(packet) { // Update packet log const logEl = document.getElementById('aprsPacketLog'); diff --git a/templates/partials/modes/aprs.html b/templates/partials/modes/aprs.html index b027fde..7b0ef89 100644 --- a/templates/partials/modes/aprs.html +++ b/templates/partials/modes/aprs.html @@ -17,6 +17,7 @@ @@ -48,4 +49,23 @@ PACKETS: 0 + +