diff --git a/static/js/modes/morse.js b/static/js/modes/morse.js index 6498db4..eed62ef 100644 --- a/static/js/modes/morse.js +++ b/static/js/modes/morse.js @@ -22,6 +22,7 @@ var MorseMode = (function () { var SCOPE_HISTORY_LEN = 300; var scopeThreshold = 0; var scopeToneOn = false; + var scopeWaiting = false; // ---- Initialization ---- @@ -150,6 +151,11 @@ var MorseMode = (function () { if (type === 'scope') { // Update scope data var amps = msg.amplitudes || []; + if (msg.waiting && amps.length === 0 && scopeHistory.length === 0) { + scopeWaiting = true; + } else if (amps.length > 0) { + scopeWaiting = false; + } for (var i = 0; i < amps.length; i++) { scopeHistory.push(amps[i]); if (scopeHistory.length > SCOPE_HISTORY_LEN) { @@ -238,6 +244,7 @@ var MorseMode = (function () { scopeCtx = canvas.getContext('2d'); scopeCtx.scale(dpr, dpr); scopeHistory = []; + scopeWaiting = false; var toneLabel = document.getElementById('morseScopeToneLabel'); var threshLabel = document.getElementById('morseScopeThreshLabel'); @@ -255,6 +262,13 @@ var MorseMode = (function () { if (threshLabel) threshLabel.textContent = scopeThreshold > 0 ? Math.round(scopeThreshold) : '--'; if (scopeHistory.length === 0) { + if (scopeWaiting) { + scopeCtx.fillStyle = '#556677'; + scopeCtx.font = '12px monospace'; + scopeCtx.textAlign = 'center'; + scopeCtx.fillText('Awaiting SDR data\u2026', w / 2, h / 2); + scopeCtx.textAlign = 'start'; + } scopeAnim = requestAnimationFrame(draw); return; } diff --git a/templates/index.html b/templates/index.html index 955e7eb..919f0da 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4256,8 +4256,8 @@ // Hide output console for modes with their own visualizations const outputEl = document.getElementById('output'); const statusBar = document.querySelector('.status-bar'); - if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall') ? 'none' : 'block'; - if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall') ? 'none' : 'flex'; + if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'sstv_general' || mode === 'wefax' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'bt_locate' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'block'; + if (statusBar) statusBar.style.display = (mode === 'satellite' || mode === 'websdr' || mode === 'subghz' || mode === 'spaceweather' || mode === 'waterfall' || mode === 'morse') ? 'none' : 'flex'; // Restore sidebar when leaving Meshtastic mode (user may have collapsed it) if (mode !== 'meshtastic') { diff --git a/tests/test_morse.py b/tests/test_morse.py index da926f9..1eb43cf 100644 --- a/tests/test_morse.py +++ b/tests/test_morse.py @@ -327,6 +327,48 @@ class TestMorseDecoderThread: t.join(timeout=5) assert not t.is_alive(), "Thread should finish after reading all data" + def test_thread_heartbeat_on_no_data(self): + """When rtl_fm produces no data, thread should emit waiting scope events.""" + import os as _os + stop = threading.Event() + q = queue.Queue(maxsize=100) + + # Create a pipe that never gets written to (simulates rtl_fm with no output) + read_fd, write_fd = _os.pipe() + read_file = _os.fdopen(read_fd, 'rb', 0) + + t = threading.Thread( + target=morse_decoder_thread, + args=(read_file, q, stop), + ) + t.daemon = True + t.start() + + # Wait up to 5 seconds for at least one heartbeat event + events = [] + import time as _time + deadline = _time.monotonic() + 5.0 + while _time.monotonic() < deadline: + try: + ev = q.get(timeout=0.5) + events.append(ev) + if ev.get('waiting'): + break + except queue.Empty: + continue + + stop.set() + _os.close(write_fd) + read_file.close() + t.join(timeout=3) + + waiting_events = [e for e in events if e.get('type') == 'scope' and e.get('waiting')] + assert len(waiting_events) >= 1, f"Expected waiting heartbeat events, got {events}" + ev = waiting_events[0] + assert ev['amplitudes'] == [] + assert ev['threshold'] == 0 + assert ev['tone_on'] is False + def test_thread_produces_events(self): """Thread should push character events to the queue.""" import io diff --git a/utils/morse.py b/utils/morse.py index 543e719..e49697e 100644 --- a/utils/morse.py +++ b/utils/morse.py @@ -7,7 +7,9 @@ from __future__ import annotations import contextlib import math +import os import queue +import select import struct import threading import time @@ -284,8 +286,26 @@ def morse_decoder_thread( ) try: + fd = rtl_stdout.fileno() + while not stop_event.is_set(): - data = rtl_stdout.read(CHUNK) + ready, _, _ = select.select([fd], [], [], 2.0) + if not ready: + # No data from SDR — emit diagnostic heartbeat + now = time.monotonic() + if now - last_scope >= SCOPE_INTERVAL: + last_scope = now + with contextlib.suppress(queue.Full): + output_queue.put_nowait({ + 'type': 'scope', + 'amplitudes': [], + 'threshold': 0, + 'tone_on': False, + 'waiting': True, + }) + continue + + data = os.read(fd, CHUNK) if not data: break